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:
oliver 2026-08-10 22:36:14 +08:00
parent aff11c1eae
commit e03c90fb35
10 changed files with 564 additions and 27 deletions

View 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"]

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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`,禁止盲重试。
## 必须加载技能

View file

@ -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 / 继续):承接上一任务继续,勿重新开查。

View file

@ -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) 诊断

View file

@ -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

View 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"

View 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()