Polish ops skills from field Q&A and tighten fiber_cut vs optical-power presets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 22:36:57 +08:00
parent 4660cc7507
commit 2f1d3983b8
5 changed files with 143 additions and 24 deletions

View file

@ -24,9 +24,32 @@ _LIST_FIELDS = [
]
# Server keyword is single-substring; presets filter client-side after a wider pull.
# fiber_cut: do NOT use bare "optical" — that pulls optical-power threshold (field CSV noise).
# Default server keyword (when caller omits keyword) biases the page toward real cut/LOS rows.
_PRESET_CLIENT_TERMS: dict[str, tuple[str, ...]] = {
"fiber_cut": ("los", "fiber", "断纤", "光缆", "光路", "optical"),
"offline": ("离线", "offline", "通信中断", "单板离线", "ne communication"),
"fiber_cut": (
"los",
"fiber break",
"fiber",
"断纤",
"光缆",
"光路",
"missing laser",
"optical module is faulty",
),
"offline": (
"离线",
"offline",
"通信中断",
"单板离线",
"ne communication",
"bn ems",
"communication failure",
),
}
_PRESET_DEFAULT_KEYWORD: dict[str, str] = {
"fiber_cut": "LOS",
"offline": "BN EMS",
}
_MODE_DEFAULT_NAMES: dict[str, str] = {
@ -196,9 +219,14 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
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: bias API keyword so the page is not drowned by PW/BGP noise (~80k rows).
preset_kw_applied = False
if mode in _PRESET_DEFAULT_KEYWORD and not keyword:
keyword = _PRESET_DEFAULT_KEYWORD[mode]
preset_kw_applied = True
# 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:
if mode in _PRESET_CLIENT_TERMS:
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]
@ -206,7 +234,12 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
filename = f"{filename}.xlsx"
sheet_name = "alarms"
summary_meta: dict[str, Any] = {"mode": mode, "keyword": keyword or None, "severity": severity or None}
summary_meta: dict[str, Any] = {
"mode": mode,
"keyword": keyword or None,
"severity": severity or None,
"preset_default_keyword": preset_kw_applied,
}
if mode == "aggregate_by_host":
upstream = _fetch_aggregate_by_host(
@ -235,7 +268,7 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
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:
if mode in _PRESET_CLIENT_TERMS:
items = _filter_preset_items(mode, items)[:page_size]
headers, rows = _rows_from_list_items(items)
summary_meta["total"] = data.get("total")
@ -278,6 +311,8 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
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. "
"fiber_cut defaults keyword=LOS (ETPI LOS / Fiber Break family; not optical-power threshold). "
"offline defaults keyword=BN EMS. Pass keyword=Fiber Break for explicit fiber-break rows. "
"Prefer this over query+write_xlsx+save_deliverable_attachment for short WA requests."
),
parameters={
@ -289,7 +324,8 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
"default": "list",
"description": (
"list=raw alarm rows; aggregate_by_host=count by host_name; "
"fiber_cut/offline=preset keyword filters for common WA intents."
"fiber_cut defaults keyword=LOS (not optical-power threshold); "
"offline defaults keyword=BN EMS."
),
},
"severity": {
@ -298,7 +334,10 @@ def ume_alarm_xlsx_report_tool() -> ToolSpec:
},
"keyword": {
"type": "string",
"description": "Optional keyword; fiber_cut/offline apply defaults when omitted.",
"description": (
"Optional keyword. If omitted: fiber_cut→LOS, offline→BN EMS. "
"Pass Fiber Break for explicit fiber-break rows."
),
},
"time_from": {"type": "string", "description": "ISO time; filters last_seen_at >="},
"time_to": {"type": "string", "description": "ISO time; filters last_seen_at <="},

View file

@ -35,12 +35,17 @@ description: 面向 ops 专家的 netx 纳管网元(网元管理)作业手
### Capacity / optical power between two names
When user says **capacity**, **bandwidth between A and B**, or **optical power A <> B** (prod vocabulary):
When user says **capacity**, **bandwidth between A and B**, **optical power A <> B**, or site pairs (`SEMBAWA <> ANGKATAN_EP`, `SMD-PSB <> SMD-PNTE`):
1. Resolve nicknames → real `host_name` via inventory/wiki (`SEMBAWA` → e.g. `PLG-SMW-EN1-…`).
2. Find interconnect: `findTopologyPaths` and/or LLDP (`show lldp …` / vendor equivalent) — identify **both ports**.
3. Read optics on **both** ends with the correct vendor command (below). Summarize: interface, RX/TX power, threshold, whether link is up.
4. Do **not** answer with only UME bandwidth-usage-rate alarms unless the user asked for congestion alarms.
4. Do **not** answer with only UME bandwidth-usage-rate **or** optical-power-threshold alarm tallies unless the user asked for those alarm lists.
5. CRC + optical on a link: same path — resolve ports once, then optic CLI (+ CRC counters if allowlisted) on **both** ends in **one** batch when possible (`targets` if vendors differ).
### Area optical-power **alarm** list (UME only)
`optical power threshold crossed` under area BPP/PBR/PAL/… → UME keyword=`optical power` + hostname prefix — **not** this CLI recipe and **not** fiber_cut.
### ZTE optical CLI (prod corrections)

View file

@ -59,15 +59,20 @@ Prefer these fixed paths for short group/DM asks (EN first; ZH aliases still wor
| User says (examples) | Recipe |
|----------------------|--------|
| fiber cut / LOS / cable cut / 断纤 | Prefer `ume_alarm_xlsx_report(mode=fiber_cut)` (xlsx + deliver); or summarize via `queryUmeAlarmsRaw` |
| offline NE / board offline / 离线 | Prefer `ume_alarm_xlsx_report(mode=offline)` |
| fiber cut / LOS / cable cut / 断纤 / sitelist | Prefer `ume_alarm_xlsx_report(mode=fiber_cut)` (defaults keyword=`LOS` → ETPI LOS; not optical-power threshold). For explicit **Fiber Break** rows also/instead `keyword=Fiber Break` or `queryUmeAlarmsRaw(keyword=Fiber Break)`. Reply with **host_name list** + counts |
| offline NE / board offline / unmanaged / 离线 | Prefer `ume_alarm_xlsx_report(mode=offline)` (defaults `BN EMS` / NE communication failure). Unmanaged ME list → same family + clarify BN EMS / unreachable |
| Critical Top / alarm tally | ① `aggregateUmeAlarms(severity=critical, top_ne=20)`; for file: `ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
| how many alarms / tally | ① `runUmeDiagnostics` or `aggregateUmeAlarms`; ② report by_severity + freshness |
| how many alarms / tally / 现网告警数量 | ① `runUmeDiagnostics` or `aggregateUmeAlarms`; ② report by_severity + freshness |
| export Excel / send spreadsheet | `ume_alarm_xlsx_report` **or** `write_xlsx(..., deliverable=true)`; never split into 3 steps |
| CRC in area PAD / ACH / … | `queryUmeAlarmsRaw(keyword=CRC)` then keep rows whose `alarm_host_name` / `ne_host_name` starts with area prefix (`PAD-`, `ACH-`, …). Optional xlsx via `write_xlsx(deliverable=true)` |
| bandwidth / congestion / usage rate (+ area) | keyword=`bandwidth` (do **not** require event_type unless user asks); filter hostname prefix for area; if CLI confirm false positives: top 3–5 NEs in **one** batch — same show → `ume_ne_ids=[…]`+`commands`; mixed vendors → `targets=[{ume_ne_id, commands},…]` — never one-NE loops |
| BN EMS / dying gasp / unmanaged (+ area) | keyword or native cause match (`BN EMS` / `dying gasp`); filter area prefix; short EN summary + optional xlsx |
| power / temperature / fan alarms (+ area/NE) | keyword=`power` / `temperature` / `fan`; scope to host or area prefix |
| bandwidth / congestion / usage rate (+ area) | keyword=`bandwidth` (matches *Send/Receive bandwidth usage rate threshold crossed*; do **not** require event_type). Filter hostname prefix for area; if CLI confirm: top 3–5 NEs in **one** batch |
| optical power **threshold** in area (BPP/PBR/PAL/…) | keyword=`optical power` (or `Input optical power`) + keep `AREA-` hosts. **Not** fiber_cut mode. Distinct from capacity A<>B CLI |
| BN EMS / dying gasp / unmanaged (+ area) | See **Dying gasp / BN EMS correlation** below — do not stop at one NE |
| power / temperature / fan / undervoltage / System Power off | keyword=`power` / `temperature` / `fan` / `undervoltage` / `Power off`; scope to host or area prefix. Optical *power(dBm)* ≠ board voltage |
| license | keyword=`License` (causes: *Permanent license abnormal*, *No enough license resource*) |
| BGP / OSPF / ISIS / LDP / PW / Tunnel on host | `queryUmeAlarmsRaw(host or keyword=BGP\|OSPF\|LDP\|…)` on named host(s); for peer correlation see below |
| Port down / ETPI / which segment cut? | keyword=`Port down` or `LOS` on the named host; use `object_name` + `findTopologyPaths` / LLDP to name the far end |
| alarm code NNNN | `queryUmeAlarmsRaw` / diagnostics `top_alarm_codes`; keyword or raw filter on code; return **host_name** list |
| alarm on **one hostname** (e.g. `MDN-PLSP`, `MKS-SWBP-EN1`) | `queryUmeAlarms` / `queryUmeAlarmsRaw` with `host_name` / keyword=hostname. **Never** start a scheduled License/daily playbook |
| alarm history / time range (e.g. `17.50-18.15`) | Resolve **WIB (UTC+7)** wall clock → `time_from`/`time_to` on `last_seen_at` / history fields; first check freshness; name hosts exactly (`MKS-KIM-CN1`) |
| is NE rebooted? / alarm history for NE | Host-scoped alarm history (reboot/reload/power related causes); answer yes/no + evidence times |
@ -80,11 +85,49 @@ Delivery rules:
### Field vocabulary (prod-learned; enforce)
- **Area** = hostname **prefix** before first extra segment: `BTM-`, `ACH-`, `MKS-`, `PAD-`, `MDN-`, `KND-`, `SMD-`, `MDO-`, `PLG-`, … Case-insensitive starts-with.
- **Capacity / bandwidth between A and B** (user correction in field): means **SFP/optical link capacity between two hostnames**, not UME bandwidth-usage-rate alarms alone. Resolve both NEs → interconnect ports (`findTopologyPaths` / LLDP) → optical/SFP CLI. See `ops-netx-managed-ne-playbook`.
- **Site nicknames** (SEMBAWA, ANGKATAN_EP, …): resolve via inventory/wiki/`queryUmeNeInventory(keyword=…)` **before** CLI; never invent hostnames.
- **Area** = hostname **prefix** before first `-`: `MDN-`, `LPG-`, `MKS-`, `PLG-`, `BJM-`, `PTK-`, `ACH-`, `PBR-`, `MDO-`, `SMD-`, `PAD-`, `BTM-`, `PLK-`, `BPP-`, `BKL-`, `JBI-`, `KND-`, `PAL-`, `GRO-`, `JAP-`, … Case-insensitive starts-with.
- **Capacity / bandwidth between A and B** / `A <> B` / site nicknames (SEMBAWA, ANGKATAN_EP): means **SFP/optical link** on the interconnect — **not** UME *bandwidth usage rate* alarms alone. Resolve both NEs → ports (`findTopologyPaths` / LLDP) → optic CLI. See `ops-netx-managed-ne-playbook`.
- **Optical power threshold crossed** (area list): UME cause *Input/Output optical power(dBm) threshold crossed* — keyword=`optical power`; **not** `mode=fiber_cut`.
- **Fiber cut / LOS sitelist**: causes *Ethernet physical (ETPI) LOS*, *Fiber Break*, *Missing laser module* — report `mode=fiber_cut` (LOS-biased) and/or `keyword=Fiber Break`.
- **Site nicknames**: resolve via inventory/wiki/`queryUmeNeInventory(keyword=…)` **before** CLI; never invent hostnames.
- **Local clock phrases** (`17.50`, `today`, `yesterday`): treat as **Asia/Jakarta (WIB, UTC+7)** unless user says otherwise.
### Field cause cheat-sheet (2026-08 snapshot vocabulary)
Use these as `keyword` / evidence labels (exact strings appear in `native_probable_cause`):
| Intent | Typical cause substrings |
|--------|---------------------------|
| Fiber / LOS | `ETPI) LOS`, `Fiber Break`, `Missing laser module` |
| Optical threshold | `Input optical power(dBm) threshold crossed`, `Output optical power` |
| Congestion | `Send bandwidth usage rate`, `Receive bandwidth usage rate` |
| CRC | `Receive CRC error frames`, `Received CRC error packet` |
| Offline / unmanaged | `BN EMS alarm NE communication failure` |
| Dying gasp | `Remote dying gasp event` |
| License | `Permanent license abnormal`, `No enough license resource` |
| Power / env | `System Power off`, `Input undervoltage`, `temperature`, `Fan module` |
| Control-plane (noisy) | `BGP Neighbour down`, `OSPF Neighbour`, `ISIS Neighbour`, `LDP Neighbour`, `State of PW in L2VPN`, `Tunnel down`, `NTP server` |
Do **not** treat PW/BGP volume leaders as “fiber cut” unless the user asked for those families.
### Dying gasp / BN EMS correlation (field-mandated)
When user mentions **dying gasp** (or correlates BN EMS with a port):
1. On the named NE: `queryUmeAlarmsRaw` with keyword=`dying gasp` (and/or host_name) — note `object_name` / slot-port and `last_seen_at`.
2. Find peer: `findTopologyPaths` and/or LLDP/CLI on that port; identify far-end `host_name`.
3. On the **peer**: look for **BN EMS** / `NE communication failure` (and related offline) with **near timestamp** (± window from step 1).
4. Reply with both sides + times + whether correlation holds. Save this as the default dying-gasp playbook — do not answer only one NE.
### Peer / protocol correlation (BGP·OSPF·LDP)
Field pattern: alarms on `HOST-A` with peer IP → confirm on `HOST-B` (or peer from topology).
1. Query both hosts (or keyword + both host filters) for the protocol family.
2. Align **occurrence / clear** times when asked.
3. Peer match: prefer exact peer / router-id; if user says so, also match **identical 3rd+4th octet** of the peer address from the alarm text.
4. Keep answers scoped to the named link (`A <> B`); do not dump unrelated area noise.
### Anti-patterns seen in field (do not repeat)
1. **Wrong playbook hijack** — User: `query alarm on MDN-AHJ-AN1` → must NOT run License/daily scheduled playbook. Answer that host’s current alarms only.
@ -93,6 +136,8 @@ Delivery rules:
4. **Apology loops** — If user asks “are you still running / why no response?”, resume the **quoted task** immediately; one short status line, then results. Do not ask what a Run ID might mean if `schedule_list` / job tools can answer.
5. **Group noise** — Pure emoji / mention-only / “hi” with no ops ask: stay minimal or silent per group policy; do not give a long “how can I help” menu.
6. **Blind CLI retries** — Wrong ZTE optic command once → switch to `show opticalinfo brief` (see managed-ne skill); do not retry the failed spelling.
7. **fiber_cut vs optical power** — Area “optical power threshold” lists must **not** use `mode=fiber_cut` (that is LOS/Fiber Break biased).
8. **Unfiltered dump** — Snapshot has ~80k uncleared rows; never list without severity/keyword/host/area/time.
### Answer shape (WhatsApp EN) — strict ops bot

View file

@ -31,10 +31,13 @@
|------|------|
| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
| 按 host 统计+Excel | `ume_alarm_xlsx_report(mode=aggregate_by_host, severity=critical)` |
| 断纤/离线清单+Excel | `ume_alarm_xlsx_report(mode=fiber_cut\|offline)` |
| 断纤/LOS 清单+Excel | `ume_alarm_xlsx_report(mode=fiber_cut)`(默认 keyword=`LOS`);纯 Fiber Break 再加 `keyword=Fiber Break` |
| 离线/BN EMS+Excel | `ume_alarm_xlsx_report(mode=offline)`(默认 `BN EMS`) |
| 区域光功率门限 | `queryUmeAlarmsRaw(keyword=optical power)` → 保留 `AREA-` 前缀;**不要** fiber_cut |
| 发 Excel(已有表数据) | `write_xlsx(..., deliverable=true)` |
| 区域 + 关键字(CRC/bandwidth/power) | `queryUmeAlarmsRaw(keyword=…)` → 过滤 `host` 前缀 `AREA-` |
| 区域 + 关键字(CRC/bandwidth/license) | `queryUmeAlarmsRaw(keyword=…)` → 过滤 `host` 前缀 `AREA-` |
| 单网元当前告警 | `queryUmeAlarms(host_name=…)` — **禁止**误跑 License 定时 playbook |
| dying gasp | 本端 dying gasp → 对端 BN EMS(近时间窗)+ 端口/拓扑;见 SKILL |
| 两端 capacity/optical | 解析两端 hostname → `findTopologyPaths` / LLDP → CLI optic(见 managed-ne) |
| 时间窗历史(WIB) | freshness → `time_from`/`time_to`(按 Asia/Jakarta) |
@ -42,11 +45,28 @@
| 用户说法 | 正确理解 |
|----------|----------|
| congestion / bandwidth usage in ACH | UME keyword bandwidth + hostname `ACH-`;要验真再 CLI top N |
| capacity A to B / optical power A <> B | **链路口 SFP/光功率**,不是单独告警 tally |
| congestion / bandwidth usage in ACH | UME keyword=`bandwidth` + hostname `ACH-`;要验真再 CLI top N |
| optical power threshold in BPP/PBR/PAL | keyword=`optical power` + 区域前缀;≠ fiber cut |
| fiber cut / LOS sitelist | `mode=fiber_cut` / keyword=`LOS` 或 `Fiber Break`;回 host 列表 |
| capacity A to B / optical power A <> B / SEMBAWA <> ANGKATAN | **链路口 SFP/光功率 CLI**,不是单独告警 tally |
| dying gasp on HOST + port | 关联对端 **BN EMS** near timestamp(现场强制配方) |
| which segment cut? + LOS host | `object_name` + topology/LLDP 找对端 |
| BGP/OSPF/LDP on HOST / A <> B | 双端协议告警 + 时间对齐;peer 可按后两段 octet |
| site SEMBAWA / ANGKATAN_EP | 先 inventory/wiki 解析成真实 `host_name` |
| `17.50 - 18.15` | WIB 当天 17:50–18:15 |
| check alarm on MDN-xxx | **仅该 host**;勿触发 daily license 等无关 playbook |
| alarm code 4758 | 按 code 过滤;列出 **host_name** |
## 3d) 现场 cause 关键字(CSV 高频)
- LOS / Fiber Break / Missing laser → 断纤类
- Input/Output optical power(dBm) threshold → 光功率门限(区域清单)
- bandwidth usage rate threshold → 拥塞
- CRC error → CRC
- BN EMS … communication failure → 离线/非管
- Remote dying gasp → 临终掉电类,必做对端关联
- Permanent license / No enough license → license
- BGP/OSPF/ISIS/LDP Neighbour down、State of PW、Tunnel down → 控制面/伪线噪声,勿当断纤
## 4) 诊断

View file

@ -113,12 +113,22 @@ 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"},
{
"alarm_native_probable_cause": "Ethernet physical (ETPI) Input optical power(dBm) threshold crossed",
"alarm_host_name": "C",
},
{"alarm_native_probable_cause": "Fiber Break", "alarm_host_name": "D"},
{"alarm_native_probable_cause": "BN EMS alarm NE communication failure", "alarm_host_name": "E"},
]
filtered = _filter_preset_items("fiber_cut", items)
assert len(filtered) == 1
hosts = {r["alarm_host_name"] for r in filtered}
assert hosts == {"A", "D"}
assert "C" not in hosts # optical-power threshold is not fiber_cut
offline = _filter_preset_items("offline", items)
assert {r["alarm_host_name"] for r in offline} == {"E"}
headers, rows = _rows_from_list_items(filtered)
assert headers[0] == "host_name"
assert rows[0][0] == "A"
assert rows[0][0] in {"A", "D"}
_, agg_rows, meta = _rows_from_aggregate_buckets(
{"buckets": [{"key": "H1", "count": 2}], "total": 2, "by_ne_missing": 0}
)