mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 06:43:16 +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 run_command_tool() -> ToolSpec:
|
||||||
def _handler(args: dict[str, Any]) -> dict[str, Any]:
|
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:
|
if not command:
|
||||||
return {"ok": False, "error_code": "command_required", "error": "command_required"}
|
return {
|
||||||
cwd = str(args.get("cwd") or "").strip() or None
|
"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)
|
timeout = int(args.get("timeout") or 300)
|
||||||
return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout)
|
return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout)
|
||||||
|
|
||||||
return ToolSpec(
|
return ToolSpec(
|
||||||
name="run_command",
|
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={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {"type": "string", "description": "Shell command to execute."},
|
"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."},
|
"cwd": {"type": "string", "description": "Optional working directory."},
|
||||||
|
"workdir": {"type": "string", "description": "Alias for cwd."},
|
||||||
"timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 300},
|
"timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 300},
|
||||||
},
|
},
|
||||||
"required": ["command"],
|
"required": [],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
handler=_handler,
|
handler=_handler,
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,12 @@ def write_xlsx_tool() -> ToolSpec:
|
||||||
return {"ok": False, "error": "path_write_failed", "detail": str(exc)}
|
return {"ok": False, "error": "path_write_failed", "detail": str(exc)}
|
||||||
|
|
||||||
meta = AttachmentAssetStore().save_bytes(blob, filename=filename, mime=XLSX_MIME)
|
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] = {
|
out: dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"attachment_id": meta.attachment_id,
|
"attachment_id": meta.attachment_id,
|
||||||
|
|
@ -224,12 +230,16 @@ def write_xlsx_tool() -> ToolSpec:
|
||||||
"bytes": meta.bytes,
|
"bytes": meta.bytes,
|
||||||
"sheet_count": len(summary),
|
"sheet_count": len(summary),
|
||||||
"sheets": 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:
|
if path_written:
|
||||||
out["path"] = path_written
|
out["path"] = path_written
|
||||||
return out
|
return out
|
||||||
|
|
@ -238,8 +248,8 @@ def write_xlsx_tool() -> ToolSpec:
|
||||||
name="write_xlsx",
|
name="write_xlsx",
|
||||||
description=(
|
description=(
|
||||||
"Build a real .xlsx workbook from structured sheet data (headers + rows) and save it "
|
"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 "
|
"to the attachment store. Returns attachment_id/name/mime/bytes. "
|
||||||
"deliverable and does NOT send to WhatsApp/WeChat. To deliver, call "
|
"Set deliverable=true to send on WhatsApp/WeChat in one step; otherwise call "
|
||||||
"save_deliverable_attachment(attachment_id=...). Prefer this over run_command/openpyxl."
|
"save_deliverable_attachment(attachment_id=...). Prefer this over run_command/openpyxl."
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
|
|
@ -343,6 +353,14 @@ def write_xlsx_tool() -> ToolSpec:
|
||||||
"default": True,
|
"default": True,
|
||||||
"description": "Best-effort column width from sample cells (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": [],
|
"required": [],
|
||||||
"additionalProperties": False,
|
"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.
|
- 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)
|
## WhatsApp interaction (mandatory)
|
||||||
- Short ops intents follow `ops-netx-ume-playbook` WhatsApp recipes; target **≤3 tool calls** per user message.
|
- 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: `write_xlsx` then `save_deliverable_attachment` — never claim a file was sent without the deliverable step.
|
- 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.
|
- Call `listCliTargets` at most once per session and reuse ids; batch `execManagedNe` commands; on timeout raise `read_timeout_sec` — no blind retries.
|
||||||
|
|
||||||
## Required skills
|
## Required skills
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@
|
||||||
- 按网元统计/聚合:优先 `aggregateUmeAlarms(group_by=alarm_host_name)` 或 `aggregateUmeAlarmsRaw`;勿按 `alarm_ne_id` / `ne_ne_id` 对外展示。
|
- 按网元统计/聚合:优先 `aggregateUmeAlarms(group_by=alarm_host_name)` 或 `aggregateUmeAlarmsRaw`;勿按 `alarm_ne_id` / `ne_ne_id` 对外展示。
|
||||||
|
|
||||||
## WhatsApp 交互(强制)
|
## WhatsApp 交互(强制)
|
||||||
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用。
|
- 短句优先走 `ops-netx-ume-playbook` 的「WhatsApp 短指令配方」,控制在 ≤3 次工具调用;要 Excel 时优先 `ume_alarm_xlsx_report`。
|
||||||
- 用户要表格/Excel:`write_xlsx` → `save_deliverable_attachment`;禁止只写文件不投递。
|
- 用户要表格/Excel:`ume_alarm_xlsx_report` 或 `write_xlsx(deliverable=true)`;禁止只写文件不投递。
|
||||||
- `listCliTargets` 每会话最多查一次并复用 id;`execManagedNe` 合并 commands,超时调 `read_timeout_sec`,禁止盲重试。
|
- `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` |
|
| 断纤 / fiber cut / LOS / 光缆中断 | **优先** `ume_alarm_xlsx_report(mode=fiber_cut)`(一键 xlsx+投递);或 `queryUmeAlarmsRaw` 后摘要 |
|
||||||
| 离线 / 单板离线 / offline NE | ① `queryUmeAlarms`/`Raw` + keyword `离线`/`offline`/`通信中断`;② 按 `alarm_host_name` 去重列清单;要文件同上 |
|
| 离线 / 单板离线 / offline NE | **优先** `ume_alarm_xlsx_report(mode=offline)` |
|
||||||
| Critical Top / 告警统计 | ① `aggregateUmeAlarms(severity=critical, top_ne=20)` 或 `group_by=alarm_host_name`;② 结论;要 xlsx 再写表 |
|
| Critical Top / 告警统计 | ① `aggregateUmeAlarms(severity=critical, top_ne=20)`;要文件:`ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
||||||
| 当前告警有多少 / tally | ① `runUmeDiagnostics` 或 `aggregateUmeAlarms`;② 直接报 by_severity + freshness |
|
| 当前告警有多少 / 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。
|
- 禁止用 `run_command`+openpyxl 造 xlsx。
|
||||||
- 确认类短句(YES / confirm / 继续):承接上一任务继续,勿重新开查。
|
- 确认类短句(YES / confirm / 继续):承接上一任务继续,勿重新开查。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@
|
||||||
| 意图 | 调用 |
|
| 意图 | 调用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
|
| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
|
||||||
| 按 host 统计 | `aggregateUmeAlarms(group_by=alarm_host_name, limit=50)` |
|
| 按 host 统计+Excel | `ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
|
||||||
| 断纤/离线清单 | `queryUmeAlarmsRaw(keyword=…, field_preset=evidence)` → 可选 `write_xlsx` + `save_deliverable_attachment` |
|
| 断纤/离线清单+Excel | `ume_alarm_xlsx_report(mode=fiber_cut\|offline)` |
|
||||||
| 发 Excel | `write_xlsx`(sheets 或顶层 headers+rows)→ `save_deliverable_attachment` |
|
| 发 Excel(已有表数据) | `write_xlsx(..., deliverable=true)` |
|
||||||
|
|
||||||
## 4) 诊断
|
## 4) 诊断
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,13 @@ description: "在 WhatsApp/微信等渠道会话中,把生成的附件发回
|
||||||
**Excel(xlsx)— 优先:**
|
**Excel(xlsx)— 优先:**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. write_xlsx(sheets=[...], name="report.xlsx") → 得到 attachment_id
|
1. write_xlsx(sheets=[...], name="report.xlsx", deliverable=true) # 一键投递
|
||||||
2. 若用户要求发文件:save_deliverable_attachment(attachment_id="...")
|
# 或仍分两步:write_xlsx → save_deliverable_attachment(attachment_id=...)
|
||||||
3. 文字回复摘要
|
2. 文字回复摘要
|
||||||
```
|
```
|
||||||
|
|
||||||
|
告警类 WhatsApp 短句优先用 ops 工具 **`ume_alarm_xlsx_report`**(查询+xlsx+投递一条完成)。
|
||||||
|
|
||||||
**文本文件(CSV/TXT):**
|
**文本文件(CSV/TXT):**
|
||||||
|
|
||||||
```text
|
```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