mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 00:40:45 +08:00
Add one-shot UME alarm Excel delivery for WhatsApp ops.
Collapse query+xlsx+deliverable into ume_alarm_xlsx_report, allow write_xlsx(deliverable=true), and accept run_command aliases to cut multi-tool loops. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
aff11c1eae
commit
e03c90fb35
10 changed files with 564 additions and 27 deletions
340
runtime/tools/experts/network_ops/ume_alarm_xlsx_report.py
Normal file
340
runtime/tools/experts/network_ops/ume_alarm_xlsx_report.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"""Always-on ops tool: query UME alarms → xlsx → optional channel deliverable in one call.
|
||||
|
||||
Independent of ``OCLAW_NETX_BUILTIN_TOOLS`` (MCP remains the primary query path; this
|
||||
collapses the WhatsApp “export Excel” loop that previously needed 3–12 tool calls).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from runtime.tools.base import ToolSpec
|
||||
from runtime.tools.experts.network_ops import netx_tools as nt
|
||||
from runtime.tools.public.write_xlsx_tool import write_xlsx_tool
|
||||
|
||||
_LIST_FIELDS = [
|
||||
"alarm_host_name",
|
||||
"alarm_perceived_severity",
|
||||
"alarm_event_type",
|
||||
"alarm_native_probable_cause",
|
||||
"alarm_object_name",
|
||||
"alarm_last_seen_at",
|
||||
"alarm_is_cleared",
|
||||
"ne_ip_address",
|
||||
]
|
||||
|
||||
# Server keyword is single-substring; presets filter client-side after a wider pull.
|
||||
_PRESET_CLIENT_TERMS: dict[str, tuple[str, ...]] = {
|
||||
"fiber_cut": ("los", "fiber", "断纤", "光缆", "光路", "optical"),
|
||||
"offline": ("离线", "offline", "通信中断", "单板离线", "ne communication"),
|
||||
}
|
||||
|
||||
_MODE_DEFAULT_NAMES: dict[str, str] = {
|
||||
"list": "ume_alarms.xlsx",
|
||||
"aggregate_by_host": "ume_alarms_by_host.xlsx",
|
||||
"fiber_cut": "fiber_cut_alarms.xlsx",
|
||||
"offline": "offline_ne_alarms.xlsx",
|
||||
}
|
||||
|
||||
|
||||
def _truthy(v: Any, *, default: bool = False) -> bool:
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
return str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _host_label(row: dict[str, Any]) -> str:
|
||||
for k in ("alarm_host_name", "host_name", "ne_host_name", "ne_user_label"):
|
||||
val = str(row.get(k) or "").strip()
|
||||
if val:
|
||||
return val
|
||||
return "(host_name missing)"
|
||||
|
||||
|
||||
def _row_blob(row: dict[str, Any]) -> str:
|
||||
parts = [
|
||||
row.get("alarm_event_type"),
|
||||
row.get("event_type"),
|
||||
row.get("alarm_native_probable_cause"),
|
||||
row.get("native_probable_cause"),
|
||||
row.get("alarm_object_name"),
|
||||
row.get("object_name"),
|
||||
row.get("alarm_alarm_key"),
|
||||
row.get("alarm_key"),
|
||||
]
|
||||
return " ".join(str(x or "") for x in parts).lower()
|
||||
|
||||
|
||||
def _filter_preset_items(mode: str, items: list[Any]) -> list[dict[str, Any]]:
|
||||
terms = _PRESET_CLIENT_TERMS.get(mode)
|
||||
if not terms:
|
||||
return [x for x in items if isinstance(x, dict)]
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in items:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
blob = _row_blob(raw)
|
||||
if any(t.lower() in blob for t in terms):
|
||||
out.append(raw)
|
||||
return out
|
||||
|
||||
|
||||
def _rows_from_list_items(items: list[Any]) -> tuple[list[str], list[list[Any]]]:
|
||||
headers = [
|
||||
"host_name",
|
||||
"severity",
|
||||
"event_type",
|
||||
"probable_cause",
|
||||
"object_name",
|
||||
"last_seen_at",
|
||||
"is_cleared",
|
||||
"ip",
|
||||
]
|
||||
rows: list[list[Any]] = []
|
||||
for raw in items:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
rows.append(
|
||||
[
|
||||
_host_label(raw),
|
||||
raw.get("alarm_perceived_severity") or raw.get("perceived_severity") or "",
|
||||
raw.get("alarm_event_type") or raw.get("event_type") or "",
|
||||
raw.get("alarm_native_probable_cause") or raw.get("native_probable_cause") or "",
|
||||
raw.get("alarm_object_name") or raw.get("object_name") or "",
|
||||
raw.get("alarm_last_seen_at") or raw.get("last_seen_at") or "",
|
||||
raw.get("alarm_is_cleared") if "alarm_is_cleared" in raw else raw.get("is_cleared"),
|
||||
raw.get("ne_ip_address") or raw.get("ip_address") or "",
|
||||
]
|
||||
)
|
||||
return headers, rows
|
||||
|
||||
|
||||
def _rows_from_aggregate_buckets(data: dict[str, Any]) -> tuple[list[str], list[list[Any]], dict[str, Any]]:
|
||||
headers = ["host_name", "count"]
|
||||
rows: list[list[Any]] = []
|
||||
# raw aggregate returns buckets as list of {key/keys, count} or similar
|
||||
buckets = data.get("buckets")
|
||||
if isinstance(buckets, list):
|
||||
for b in buckets:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
key = b.get("key")
|
||||
if key is None and isinstance(b.get("keys"), (list, tuple)) and b.get("keys"):
|
||||
key = b["keys"][0]
|
||||
if key is None:
|
||||
key = b.get("alarm_host_name") or b.get("host_name") or b.get("group")
|
||||
rows.append([str(key or "(host_name missing)"), int(b.get("count") or b.get("n") or 0)])
|
||||
elif isinstance(data.get("by_ne"), list):
|
||||
for b in data["by_ne"]:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
rows.append(
|
||||
[
|
||||
str(b.get("host_name") or b.get("ne") or b.get("key") or "(host_name missing)"),
|
||||
int(b.get("count") or b.get("n") or 0),
|
||||
]
|
||||
)
|
||||
meta = {
|
||||
"total": data.get("total"),
|
||||
"by_ne_missing": data.get("by_ne_missing"),
|
||||
"group_by": data.get("group_by") or "alarm_host_name",
|
||||
"meta": data.get("meta"),
|
||||
}
|
||||
return headers, rows, meta
|
||||
|
||||
|
||||
def _fetch_list(*, keyword: str, severity: str, time_from: str, time_to: str, page_size: int) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"page": 1,
|
||||
"page_size": page_size,
|
||||
"select_fields": ",".join(_LIST_FIELDS),
|
||||
}
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
if severity:
|
||||
params["severity"] = severity
|
||||
if time_from:
|
||||
params["time_from"] = time_from
|
||||
if time_to:
|
||||
params["time_to"] = time_to
|
||||
return nt._http_json("GET", "/v1/ume/alarms/raw", params=params)
|
||||
|
||||
|
||||
def _fetch_aggregate_by_host(
|
||||
*, severity: str, keyword: str, time_from: str, time_to: str, limit: int
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {
|
||||
"group_by": "alarm_host_name",
|
||||
"limit": limit,
|
||||
"exclude_missing_host": True,
|
||||
}
|
||||
if severity:
|
||||
params["severity"] = severity
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
if time_from:
|
||||
params["time_from"] = time_from
|
||||
if time_to:
|
||||
params["time_to"] = time_to
|
||||
return nt._http_json("GET", "/v1/ume/alarms/aggregate/raw", params=params)
|
||||
|
||||
|
||||
def ume_alarm_xlsx_report_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
mode = str(args.get("mode") or "list").strip().lower() or "list"
|
||||
if mode not in _MODE_DEFAULT_NAMES:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "invalid_mode",
|
||||
"allowed": sorted(_MODE_DEFAULT_NAMES.keys()),
|
||||
}
|
||||
|
||||
severity = str(args.get("severity") or "").strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
time_from = str(args.get("time_from") or "").strip()
|
||||
time_to = str(args.get("time_to") or "").strip()
|
||||
page_size = max(1, min(500, int(args.get("page_size") or args.get("limit") or 100)))
|
||||
# Preset modes pull a wider page then filter client-side (API keyword is single substring).
|
||||
fetch_size = page_size
|
||||
if mode in _PRESET_CLIENT_TERMS and not keyword:
|
||||
fetch_size = max(page_size, min(500, page_size * 3))
|
||||
deliverable = _truthy(args.get("deliverable"), default=True)
|
||||
filename = str(args.get("name") or args.get("filename") or "").strip() or _MODE_DEFAULT_NAMES[mode]
|
||||
if not filename.lower().endswith(".xlsx"):
|
||||
filename = f"{filename}.xlsx"
|
||||
|
||||
sheet_name = "alarms"
|
||||
summary_meta: dict[str, Any] = {"mode": mode, "keyword": keyword or None, "severity": severity or None}
|
||||
|
||||
if mode == "aggregate_by_host":
|
||||
upstream = _fetch_aggregate_by_host(
|
||||
severity=severity,
|
||||
keyword=keyword,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
limit=page_size,
|
||||
)
|
||||
if not upstream.get("ok"):
|
||||
return {"ok": False, "error": "ume_query_failed", "upstream": upstream}
|
||||
data = upstream.get("data") if isinstance(upstream.get("data"), dict) else {}
|
||||
headers, rows, agg_meta = _rows_from_aggregate_buckets(data)
|
||||
summary_meta.update(agg_meta)
|
||||
sheet_name = "by_host"
|
||||
else:
|
||||
# list / fiber_cut / offline share raw list path
|
||||
upstream = _fetch_list(
|
||||
keyword=keyword,
|
||||
severity=severity,
|
||||
time_from=time_from,
|
||||
time_to=time_to,
|
||||
page_size=fetch_size,
|
||||
)
|
||||
if not upstream.get("ok"):
|
||||
return {"ok": False, "error": "ume_query_failed", "upstream": upstream}
|
||||
data = upstream.get("data") if isinstance(upstream.get("data"), dict) else {}
|
||||
items = data.get("items") if isinstance(data.get("items"), list) else []
|
||||
if mode in _PRESET_CLIENT_TERMS and not keyword:
|
||||
items = _filter_preset_items(mode, items)[:page_size]
|
||||
headers, rows = _rows_from_list_items(items)
|
||||
summary_meta["total"] = data.get("total")
|
||||
summary_meta["returned"] = len(rows)
|
||||
summary_meta["meta"] = data.get("meta")
|
||||
if mode == "fiber_cut":
|
||||
sheet_name = "fiber_cut"
|
||||
elif mode == "offline":
|
||||
sheet_name = "offline"
|
||||
|
||||
xlsx = write_xlsx_tool().handler(
|
||||
{
|
||||
"name": filename,
|
||||
"deliverable": deliverable,
|
||||
"sheets": [{"name": sheet_name, "headers": headers, "rows": rows}],
|
||||
}
|
||||
)
|
||||
if not xlsx.get("ok"):
|
||||
return {"ok": False, "error": "xlsx_build_failed", "detail": xlsx}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"attachment_id": xlsx.get("attachment_id"),
|
||||
"name": xlsx.get("name"),
|
||||
"mime": xlsx.get("mime"),
|
||||
"bytes": xlsx.get("bytes"),
|
||||
"deliverable": bool(xlsx.get("deliverable")),
|
||||
"row_count": len(rows),
|
||||
"columns": headers,
|
||||
"summary": summary_meta,
|
||||
"hint": (
|
||||
"Report ready and marked deliverable for channel send."
|
||||
if deliverable
|
||||
else "Report saved; call save_deliverable_attachment to send on WhatsApp."
|
||||
),
|
||||
}
|
||||
|
||||
return ToolSpec(
|
||||
name="ume_alarm_xlsx_report",
|
||||
description=(
|
||||
"One-shot UME alarm Excel for WhatsApp ops: query netx alarms and build .xlsx "
|
||||
"(optionally mark deliverable). Modes: list, aggregate_by_host, fiber_cut, offline. "
|
||||
"Prefer this over query+write_xlsx+save_deliverable_attachment for short WA requests."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["list", "aggregate_by_host", "fiber_cut", "offline"],
|
||||
"default": "list",
|
||||
"description": (
|
||||
"list=raw alarm rows; aggregate_by_host=count by host_name; "
|
||||
"fiber_cut/offline=preset keyword filters for common WA intents."
|
||||
),
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"description": "Optional perceived_severity filter (critical/major/minor/warning).",
|
||||
},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "Optional keyword; fiber_cut/offline apply defaults when omitted.",
|
||||
},
|
||||
"time_from": {"type": "string", "description": "ISO time; filters last_seen_at >="},
|
||||
"time_to": {"type": "string", "description": "ISO time; filters last_seen_at <="},
|
||||
"page_size": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"default": 100,
|
||||
"description": "Max rows / aggregate buckets (alias: limit).",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"description": "Alias for page_size.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Output filename, e.g. fiber_cut.xlsx",
|
||||
},
|
||||
"filename": {"type": "string", "description": "Alias for name."},
|
||||
"deliverable": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Mark for WhatsApp/WeChat delivery (default true).",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
tags=frozenset({"netx", "ops", "ume", "alarms", "xlsx", "attachment"}),
|
||||
read_only=False,
|
||||
risk_level="low",
|
||||
timeout_s=90.0,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ume_alarm_xlsx_report_tool"]
|
||||
|
|
@ -8,24 +8,36 @@ from runtime.tools.public.local_sdk import get_local_adapter
|
|||
|
||||
def run_command_tool() -> ToolSpec:
|
||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
command = str(args.get("command") or "").strip()
|
||||
command = str(
|
||||
args.get("command") or args.get("cmd") or args.get("shell") or args.get("script") or ""
|
||||
).strip()
|
||||
if not command:
|
||||
return {"ok": False, "error_code": "command_required", "error": "command_required"}
|
||||
cwd = str(args.get("cwd") or "").strip() or None
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": "command_required",
|
||||
"error": "command_required",
|
||||
"hint": "Pass command (aliases: cmd, shell).",
|
||||
"example": {"command": "echo hello", "timeout": 60},
|
||||
}
|
||||
cwd = str(args.get("cwd") or args.get("workdir") or "").strip() or None
|
||||
timeout = int(args.get("timeout") or 300)
|
||||
return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout)
|
||||
|
||||
return ToolSpec(
|
||||
name="run_command",
|
||||
description="Run a shell command via local backend.",
|
||||
description="Run a shell command via local backend. Prefer cmd aliases: command/cmd/shell.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "Shell command to execute."},
|
||||
"cmd": {"type": "string", "description": "Alias for command."},
|
||||
"shell": {"type": "string", "description": "Alias for command."},
|
||||
"script": {"type": "string", "description": "Alias for command."},
|
||||
"cwd": {"type": "string", "description": "Optional working directory."},
|
||||
"workdir": {"type": "string", "description": "Alias for cwd."},
|
||||
"timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 300},
|
||||
},
|
||||
"required": ["command"],
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
handler=_handler,
|
||||
|
|
|
|||
|
|
@ -216,6 +216,12 @@ def write_xlsx_tool() -> ToolSpec:
|
|||
return {"ok": False, "error": "path_write_failed", "detail": str(exc)}
|
||||
|
||||
meta = AttachmentAssetStore().save_bytes(blob, filename=filename, mime=XLSX_MIME)
|
||||
deliverable = args.get("deliverable") is True or str(args.get("deliverable") or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"attachment_id": meta.attachment_id,
|
||||
|
|
@ -224,12 +230,16 @@ def write_xlsx_tool() -> ToolSpec:
|
|||
"bytes": meta.bytes,
|
||||
"sheet_count": len(summary),
|
||||
"sheets": summary,
|
||||
"hint": (
|
||||
"Excel saved to attachment store. Not sent to channel yet. "
|
||||
"If the user asked to receive the file, call save_deliverable_attachment "
|
||||
"with this attachment_id."
|
||||
),
|
||||
}
|
||||
if deliverable:
|
||||
out["deliverable"] = True
|
||||
out["hint"] = "Excel marked deliverable — channel outbound will attach this file."
|
||||
else:
|
||||
out["hint"] = (
|
||||
"Excel saved to attachment store. Not sent to channel yet. "
|
||||
"If the user asked to receive the file, either re-call with deliverable=true "
|
||||
"or call save_deliverable_attachment with this attachment_id."
|
||||
)
|
||||
if path_written:
|
||||
out["path"] = path_written
|
||||
return out
|
||||
|
|
@ -238,8 +248,8 @@ def write_xlsx_tool() -> ToolSpec:
|
|||
name="write_xlsx",
|
||||
description=(
|
||||
"Build a real .xlsx workbook from structured sheet data (headers + rows) and save it "
|
||||
"to the attachment store. Returns attachment_id/name/mime/bytes — does NOT mark "
|
||||
"deliverable and does NOT send to WhatsApp/WeChat. To deliver, call "
|
||||
"to the attachment store. Returns attachment_id/name/mime/bytes. "
|
||||
"Set deliverable=true to send on WhatsApp/WeChat in one step; otherwise call "
|
||||
"save_deliverable_attachment(attachment_id=...). Prefer this over run_command/openpyxl."
|
||||
),
|
||||
parameters={
|
||||
|
|
@ -343,6 +353,14 @@ def write_xlsx_tool() -> ToolSpec:
|
|||
"default": True,
|
||||
"description": "Best-effort column width from sample cells (default true).",
|
||||
},
|
||||
"deliverable": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"If true, mark the workbook for channel delivery (WhatsApp/WeChat) "
|
||||
"without a separate save_deliverable_attachment call."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ You are the ops specialist (network operations expert).
|
|||
- NE stats/aggregates: prefer `aggregateUmeAlarms(group_by=alarm_host_name)` or `aggregateUmeAlarmsRaw`; do not group by `alarm_ne_id` / `ne_ne_id` for user output.
|
||||
|
||||
## WhatsApp interaction (mandatory)
|
||||
- Short ops intents follow `ops-netx-ume-playbook` WhatsApp recipes; target **≤3 tool calls** per user message.
|
||||
- Spreadsheet delivery: `write_xlsx` then `save_deliverable_attachment` — never claim a file was sent without the deliverable step.
|
||||
- Short ops intents follow `ops-netx-ume-playbook` WhatsApp recipes; target **≤3 tool calls** per user message. For Excel exports prefer `ume_alarm_xlsx_report`.
|
||||
- Spreadsheet delivery: `ume_alarm_xlsx_report` or `write_xlsx(deliverable=true)` — never claim a file was sent without deliverable marking.
|
||||
- Call `listCliTargets` at most once per session and reuse ids; batch `execManagedNe` commands; on timeout raise `read_timeout_sec` — no blind retries.
|
||||
|
||||
## Required skills
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@
|
|||
- 按网元统计/聚合:优先 `aggregateUmeAlarms(group_by=alarm_host_name)` 或 `aggregateUmeAlarmsRaw`;勿按 `alarm_ne_id` / `ne_ne_id` 对外展示。
|
||||
|
||||
## WhatsApp 交互(强制)
|
||||
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用。
|
||||
- 用户要表格/Excel:`write_xlsx` → `save_deliverable_attachment`;禁止只写文件不投递。
|
||||
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用;要 Excel 时优先 `ume_alarm_xlsx_report`。
|
||||
- 用户要表格/Excel:`ume_alarm_xlsx_report` 或 `write_xlsx(deliverable=true)`;禁止只写文件不投递。
|
||||
- `listCliTargets` 每会话最多查一次并复用 id;`execManagedNe` 合并 commands,超时调 `read_timeout_sec`,禁止盲重试。
|
||||
|
||||
## 必须加载技能
|
||||
|
|
|
|||
|
|
@ -58,14 +58,15 @@ description: 面向 ops 专家的 netx UME 运维作业手册。覆盖告警查
|
|||
|
||||
| 用户说法(例) | 配方 |
|
||||
|----------------|------|
|
||||
| 断纤 / fiber cut / LOS / 光缆中断 | ① `queryUmeAlarmsRaw(keyword=LOS\|fiber\|断纤\|光缆, field_preset=evidence, page_size=50)` 或 `keyword`+severity;② 摘要表;若要文件:`write_xlsx`→`save_deliverable_attachment` |
|
||||
| 离线 / 单板离线 / offline NE | ① `queryUmeAlarms`/`Raw` + keyword `离线`/`offline`/`通信中断`;② 按 `alarm_host_name` 去重列清单;要文件同上 |
|
||||
| Critical Top / 告警统计 | ① `aggregateUmeAlarms(severity=critical, top_ne=20)` 或 `group_by=alarm_host_name`;② 结论;要 xlsx 再写表 |
|
||||
| 断纤 / fiber cut / LOS / 光缆中断 | **优先** `ume_alarm_xlsx_report(mode=fiber_cut)`(一键 xlsx+投递);或 `queryUmeAlarmsRaw` 后摘要 |
|
||||
| 离线 / 单板离线 / offline NE | **优先** `ume_alarm_xlsx_report(mode=offline)` |
|
||||
| Critical Top / 告警统计 | ① `aggregateUmeAlarms(severity=critical, top_ne=20)`;要文件:`ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
||||
| 当前告警有多少 / tally | ① `runUmeDiagnostics` 或 `aggregateUmeAlarms`;② 直接报 by_severity + freshness |
|
||||
| 导出 Excel / 发我表格 | 先查再 `write_xlsx(sheets=[…])`(可顶层 headers+rows)→ **必须** `save_deliverable_attachment(attachment_id=…)` |
|
||||
| 导出 Excel / 发我表格 | `ume_alarm_xlsx_report` **或** `write_xlsx(..., deliverable=true)`;勿再拆成 3 步 |
|
||||
|
||||
交付约定:
|
||||
- `write_xlsx` 只入库,不发出;WhatsApp 要文件时最后一步必须 `save_deliverable_attachment`。
|
||||
- `ume_alarm_xlsx_report` 默认 `deliverable=true`,WhatsApp 可直接收文件。
|
||||
- 通用表格:`write_xlsx(..., deliverable=true)` 可跳过 `save_deliverable_attachment`。
|
||||
- 禁止用 `run_command`+openpyxl 造 xlsx。
|
||||
- 确认类短句(YES / confirm / 继续):承接上一任务继续,勿重新开查。
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@
|
|||
| 意图 | 调用 |
|
||||
|------|------|
|
||||
| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
|
||||
| 按 host 统计 | `aggregateUmeAlarms(group_by=alarm_host_name, limit=50)` |
|
||||
| 断纤/离线清单 | `queryUmeAlarmsRaw(keyword=…, field_preset=evidence)` → 可选 `write_xlsx` + `save_deliverable_attachment` |
|
||||
| 发 Excel | `write_xlsx`(sheets 或顶层 headers+rows)→ `save_deliverable_attachment` |
|
||||
| 按 host 统计+Excel | `ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
||||
| 断纤/离线清单+Excel | `ume_alarm_xlsx_report(mode=fiber_cut\|offline)` |
|
||||
| 发 Excel(已有表数据) | `write_xlsx(..., deliverable=true)` |
|
||||
|
||||
## 4) 诊断
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,13 @@ description: "在 WhatsApp/微信等渠道会话中,把生成的附件发回
|
|||
**Excel(xlsx)— 优先:**
|
||||
|
||||
```text
|
||||
1. write_xlsx(sheets=[...], name="report.xlsx") → 得到 attachment_id
|
||||
2. 若用户要求发文件:save_deliverable_attachment(attachment_id="...")
|
||||
3. 文字回复摘要
|
||||
1. write_xlsx(sheets=[...], name="report.xlsx", deliverable=true) # 一键投递
|
||||
# 或仍分两步:write_xlsx → save_deliverable_attachment(attachment_id=...)
|
||||
2. 文字回复摘要
|
||||
```
|
||||
|
||||
告警类 WhatsApp 短句优先用 ops 工具 **`ume_alarm_xlsx_report`**(查询+xlsx+投递一条完成)。
|
||||
|
||||
**文本文件(CSV/TXT):**
|
||||
|
||||
```text
|
||||
|
|
|
|||
26
tests/test_run_command_aliases.py
Normal file
26
tests/test_run_command_aliases.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from runtime.tools.public.run_command_tool import run_command_tool
|
||||
from runtime.tools.tool_validation import filter_arguments_to_schema, validate_tool_arguments
|
||||
|
||||
|
||||
def test_run_command_accepts_cmd_alias(monkeypatch) -> None:
|
||||
calls: list[dict] = []
|
||||
|
||||
class _Adapter:
|
||||
def run_command(self, *, command: str, cwd=None, timeout: int = 300):
|
||||
calls.append({"command": command, "cwd": cwd, "timeout": timeout})
|
||||
return {"ok": True, "stdout": "hi", "stderr": "", "exit_code": 0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.public.run_command_tool.get_local_adapter",
|
||||
lambda: _Adapter(),
|
||||
)
|
||||
spec = run_command_tool()
|
||||
args = {"cmd": "echo hi", "timeout": 10}
|
||||
filtered = filter_arguments_to_schema(spec.parameters, args)
|
||||
ok, err = validate_tool_arguments(spec.parameters, filtered)
|
||||
assert ok, err
|
||||
out = spec.handler(filtered)
|
||||
assert out.get("ok") is True
|
||||
assert calls[0]["command"] == "echo hi"
|
||||
138
tests/test_ume_alarm_xlsx_report.py
Normal file
138
tests/test_ume_alarm_xlsx_report.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from runtime.tools.experts.network_ops.ume_alarm_xlsx_report import (
|
||||
_filter_preset_items,
|
||||
_rows_from_aggregate_buckets,
|
||||
_rows_from_list_items,
|
||||
ume_alarm_xlsx_report_tool,
|
||||
)
|
||||
from runtime.tools.public.write_xlsx_tool import write_xlsx_tool
|
||||
from svc.files.attachment_assets import AttachmentAssetStore
|
||||
|
||||
|
||||
def test_write_xlsx_deliverable_flag(tmp_path, monkeypatch) -> None:
|
||||
store = AttachmentAssetStore(root_dir=tmp_path / "att")
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.public.write_xlsx_tool.AttachmentAssetStore",
|
||||
lambda root_dir=None: store if root_dir is None else AttachmentAssetStore(root_dir=root_dir),
|
||||
)
|
||||
out = write_xlsx_tool().handler(
|
||||
{
|
||||
"headers": ["a"],
|
||||
"rows": [[1]],
|
||||
"deliverable": True,
|
||||
"name": "d.xlsx",
|
||||
}
|
||||
)
|
||||
assert out.get("ok") is True
|
||||
assert out.get("deliverable") is True
|
||||
|
||||
|
||||
def test_ume_alarm_xlsx_report_list_mocked(tmp_path, monkeypatch) -> None:
|
||||
store = AttachmentAssetStore(root_dir=tmp_path / "att")
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.public.write_xlsx_tool.AttachmentAssetStore",
|
||||
lambda root_dir=None: store if root_dir is None else AttachmentAssetStore(root_dir=root_dir),
|
||||
)
|
||||
|
||||
def fake_http(method, path, *, params=None):
|
||||
assert path == "/v1/ume/alarms/raw"
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"total": 2,
|
||||
"items": [
|
||||
{
|
||||
"alarm_host_name": "NE-A",
|
||||
"alarm_perceived_severity": "critical",
|
||||
"alarm_event_type": "LOS",
|
||||
"alarm_native_probable_cause": "fiber cut",
|
||||
"alarm_object_name": "port-1",
|
||||
"alarm_last_seen_at": "2026-08-01T00:00:00Z",
|
||||
"alarm_is_cleared": "false",
|
||||
"ne_ip_address": "10.0.0.1",
|
||||
},
|
||||
{
|
||||
"alarm_host_name": "NE-B",
|
||||
"alarm_perceived_severity": "major",
|
||||
"alarm_event_type": "other",
|
||||
"alarm_native_probable_cause": "fan",
|
||||
"alarm_object_name": "fan-1",
|
||||
"alarm_last_seen_at": "2026-08-01T01:00:00Z",
|
||||
"alarm_is_cleared": "false",
|
||||
"ne_ip_address": "10.0.0.2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.experts.network_ops.ume_alarm_xlsx_report.nt._http_json",
|
||||
fake_http,
|
||||
)
|
||||
out = ume_alarm_xlsx_report_tool().handler({"mode": "fiber_cut", "page_size": 50})
|
||||
assert out.get("ok") is True
|
||||
assert out.get("deliverable") is True
|
||||
assert out.get("row_count") == 1
|
||||
assert out.get("attachment_id")
|
||||
assert out["summary"]["mode"] == "fiber_cut"
|
||||
|
||||
|
||||
def test_ume_alarm_xlsx_report_aggregate_mocked(tmp_path, monkeypatch) -> None:
|
||||
store = AttachmentAssetStore(root_dir=tmp_path / "att")
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.public.write_xlsx_tool.AttachmentAssetStore",
|
||||
lambda root_dir=None: store if root_dir is None else AttachmentAssetStore(root_dir=root_dir),
|
||||
)
|
||||
|
||||
def fake_http(method, path, *, params=None):
|
||||
assert path == "/v1/ume/alarms/aggregate/raw"
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"total": 10,
|
||||
"group_by": "alarm_host_name",
|
||||
"by_ne_missing": 1,
|
||||
"buckets": [{"key": "NE-A", "count": 7}, {"key": "NE-B", "count": 3}],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"runtime.tools.experts.network_ops.ume_alarm_xlsx_report.nt._http_json",
|
||||
fake_http,
|
||||
)
|
||||
out = ume_alarm_xlsx_report_tool().handler(
|
||||
{"mode": "aggregate_by_host", "severity": "critical", "deliverable": False}
|
||||
)
|
||||
assert out.get("ok") is True
|
||||
assert out.get("deliverable") is False
|
||||
assert out.get("row_count") == 2
|
||||
|
||||
|
||||
def test_filter_preset_and_row_helpers() -> None:
|
||||
items = [
|
||||
{"alarm_event_type": "Communication LOS", "alarm_host_name": "A"},
|
||||
{"alarm_event_type": "fan fail", "alarm_host_name": "B"},
|
||||
]
|
||||
filtered = _filter_preset_items("fiber_cut", items)
|
||||
assert len(filtered) == 1
|
||||
headers, rows = _rows_from_list_items(filtered)
|
||||
assert headers[0] == "host_name"
|
||||
assert rows[0][0] == "A"
|
||||
_, agg_rows, meta = _rows_from_aggregate_buckets(
|
||||
{"buckets": [{"key": "H1", "count": 2}], "total": 2, "by_ne_missing": 0}
|
||||
)
|
||||
assert agg_rows == [["H1", 2]]
|
||||
assert meta["total"] == 2
|
||||
|
||||
|
||||
def test_ume_alarm_xlsx_report_registered_when_builtin_disabled(monkeypatch) -> None:
|
||||
from runtime.tools import expert_registry
|
||||
|
||||
monkeypatch.delenv("OCLAW_NETX_BUILTIN_TOOLS", raising=False)
|
||||
expert_registry.clear_expert_tool_cache()
|
||||
factories = expert_registry.discover_expert_tool_factories()
|
||||
names = {f().name for f in (factories.get("network_ops") or [])}
|
||||
assert "ume_alarm_xlsx_report" in names
|
||||
assert not any(n.startswith("netx_") for n in names)
|
||||
expert_registry.clear_expert_tool_cache()
|
||||
Loading…
Add table
Add a link
Reference in a new issue