diff --git a/runtime/tools/experts/network_ops/ume_alarm_xlsx_report.py b/runtime/tools/experts/network_ops/ume_alarm_xlsx_report.py new file mode 100644 index 00000000..b8794940 --- /dev/null +++ b/runtime/tools/experts/network_ops/ume_alarm_xlsx_report.py @@ -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"] diff --git a/runtime/tools/public/run_command_tool.py b/runtime/tools/public/run_command_tool.py index 34be32a2..79d8503e 100644 --- a/runtime/tools/public/run_command_tool.py +++ b/runtime/tools/public/run_command_tool.py @@ -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, diff --git a/runtime/tools/public/write_xlsx_tool.py b/runtime/tools/public/write_xlsx_tool.py index 80062194..e01a7277 100644 --- a/runtime/tools/public/write_xlsx_tool.py +++ b/runtime/tools/public/write_xlsx_tool.py @@ -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, diff --git a/runtime/workspaces/ops/ROLE_SYSTEM.en.md b/runtime/workspaces/ops/ROLE_SYSTEM.en.md index 7c56416a..37971054 100644 --- a/runtime/workspaces/ops/ROLE_SYSTEM.en.md +++ b/runtime/workspaces/ops/ROLE_SYSTEM.en.md @@ -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 diff --git a/runtime/workspaces/ops/ROLE_SYSTEM.md b/runtime/workspaces/ops/ROLE_SYSTEM.md index 89020539..baa8aeab 100644 --- a/runtime/workspaces/ops/ROLE_SYSTEM.md +++ b/runtime/workspaces/ops/ROLE_SYSTEM.md @@ -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`,禁止盲重试。 ## 必须加载技能 diff --git a/skills/_workspace/ops/ops-netx-ume-playbook/SKILL.md b/skills/_workspace/ops/ops-netx-ume-playbook/SKILL.md index 1ba438a1..d17f1e8c 100644 --- a/skills/_workspace/ops/ops-netx-ume-playbook/SKILL.md +++ b/skills/_workspace/ops/ops-netx-ume-playbook/SKILL.md @@ -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 / 继续):承接上一任务继续,勿重新开查。 diff --git a/skills/_workspace/ops/ops-netx-ume-playbook/reference.md b/skills/_workspace/ops/ops-netx-ume-playbook/reference.md index 69af3f7a..c8efc811 100644 --- a/skills/_workspace/ops/ops-netx-ume-playbook/reference.md +++ b/skills/_workspace/ops/ops-netx-ume-playbook/reference.md @@ -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) 诊断 diff --git a/skills/_workspace/public/channel-file-delivery/SKILL.md b/skills/_workspace/public/channel-file-delivery/SKILL.md index 50f01f39..025174ff 100644 --- a/skills/_workspace/public/channel-file-delivery/SKILL.md +++ b/skills/_workspace/public/channel-file-delivery/SKILL.md @@ -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 diff --git a/tests/test_run_command_aliases.py b/tests/test_run_command_aliases.py new file mode 100644 index 00000000..a6ab25d1 --- /dev/null +++ b/tests/test_run_command_aliases.py @@ -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" diff --git a/tests/test_ume_alarm_xlsx_report.py b/tests/test_ume_alarm_xlsx_report.py new file mode 100644 index 00000000..7d36fc13 --- /dev/null +++ b/tests/test_ume_alarm_xlsx_report.py @@ -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()