netx/netx_api/mcp_remote/topology/layout_sight.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

286 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Layout "sight" for agents: crossing hotspots + spatial blocks with coords.
Gives enough geometry to hand-drag like a human without loading the full canvas
into chat. Used by analyzeTopologyViewLayout(detail=hotspots|blocks).
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
collapse_links,
segments_properly_intersect,
top_crossing_edges,
top_crossing_nodes,
)
def _short_name(name: str) -> str:
parts = (name or "").split("-")
return parts[1] if len(parts) >= 2 else (name or "")[:16]
def _pos_map(nodes: list[dict[str, Any]]) -> dict[str, tuple[float, float, str]]:
out: dict[str, tuple[float, float, str]] = {}
for n in nodes:
if not isinstance(n, dict):
continue
nid = str(n.get("fabric_node_id") or "").strip()
if not nid:
continue
try:
x, y = float(n.get("x")), float(n.get("y"))
except (TypeError, ValueError):
continue
out[nid] = (x, y, str(n.get("name") or nid))
return out
def list_crossings(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
limit: int = 40,
) -> dict[str, Any]:
pos = _pos_map(nodes)
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
crosses: list[dict[str, Any]] = []
hit: dict[str, int] = {}
edge_hit: dict[tuple[str, str], int] = {}
total_cross = 0
for i, (a, b) in enumerate(links):
p1, p2 = (pos[a][0], pos[a][1]), (pos[b][0], pos[b][1])
e1 = (a, b) if a < b else (b, a)
for c, d in links[i + 1 :]:
if len({a, b, c, d}) < 4:
continue
p3, p4 = (pos[c][0], pos[c][1]), (pos[d][0], pos[d][1])
if not segments_properly_intersect(p1, p2, p3, p4):
continue
total_cross += 1
e2 = (c, d) if c < d else (d, c)
for nid in (a, b, c, d):
hit[nid] = hit.get(nid, 0) + 1
edge_hit[e1] = edge_hit.get(e1, 0) + 1
edge_hit[e2] = edge_hit.get(e2, 0) + 1
if len(crosses) < limit:
crosses.append(
{
"e1": {
"a": a,
"b": b,
"a_name": _short_name(pos[a][2]),
"b_name": _short_name(pos[b][2]),
},
"e2": {
"a": c,
"b": d,
"a_name": _short_name(pos[c][2]),
"b_name": _short_name(pos[d][2]),
},
}
)
# Rank drag candidates: high crossing participation / low degree.
adj: dict[str, set[str]] = {n: set() for n in pos}
for a, b in links:
adj[a].add(b)
adj[b].add(a)
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
names = {k: v[2] for k, v in pos.items()}
candidates = []
for nid, cnt in sorted(hit.items(), key=lambda kv: -kv[1]):
deg = len(adj.get(nid, ()))
if deg >= 8:
continue
x, y, name = pos[nid]
suggest = orbit_lite_suggest(
nid,
pos_xy,
links,
adj,
names,
max_jump=360.0,
angle_step=45,
top_k=3,
)
best_delta = suggest[0]["delta_crossings_est"] if suggest else 0
candidates.append(
{
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": cnt,
"score": round(cnt / max(deg, 1), 3),
"suggest_xy": suggest[:3],
"delta_crossings_est": best_delta,
}
)
if len(candidates) >= min(30, limit):
break
top5 = top_crossing_nodes(
pos_xy, links, names=names, adj=adj, top_n=5, participation=hit
)
top_e = top_crossing_edges(
pos_xy, links, names=names, top_n=5, edge_participation=edge_hit
)
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
if not candidates:
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
for eh in (ec.get("top_edge_hits") or [])[:5]:
nid = str(eh.get("fabric_node_id") or "")
if not nid or nid not in pos:
continue
x, y, name = pos[nid]
deg = len(adj.get(nid, ()))
candidates.append({
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": hit.get(nid, 0),
"score": 0.0,
"suggest_xy": [],
"delta_crossings_est": 0,
"reason": "edge_clearance_hit",
})
for tn in top5:
nid = str(tn.get("fabric_node_id") or "")
if not nid or nid not in pos:
continue
if any(c["fabric_node_id"] == nid for c in candidates):
continue
x, y, name = pos[nid]
deg = len(adj.get(nid, ()))
candidates.append({
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": tn.get("crossing_hits", 0),
"score": round(tn.get("crossing_hits", 0) / max(deg, 1), 3),
"suggest_xy": [],
"delta_crossings_est": 0,
"reason": "top_crossing_high_degree",
})
return {
"edge_crossings": total_cross,
"crossings_listed": len(crosses),
"crossings": crosses,
"top_nodes": top5,
"top_edges": top_e,
"drag_candidates": candidates,
"tip": (
"先看 top_nodes / top_edges(交叉最重前5网元与前5边);"
"再动 drag_candidates(低度数可挪点,suggest_xy=orbit_lite);"
"完整扫角用 layoutTopologyView action=orbit_sweep(preview,node_id)→pick。"
"勿本地穷举坐标。"
),
}
def spatial_blocks(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
cell: float = 600.0,
top_k: int = 12,
) -> dict[str, Any]:
"""Bucket nodes into coarse grid cells; report densest / most-crossing blocks."""
pos = _pos_map(nodes)
if not pos or cell <= 1:
return {"blocks": [], "cell": cell}
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
def cell_key(x: float, y: float) -> tuple[int, int]:
return (int(math.floor(x / cell)), int(math.floor(y / cell)))
buckets: dict[tuple[int, int], list[str]] = {}
for nid, (x, y, _) in pos.items():
buckets.setdefault(cell_key(x, y), []).append(nid)
# Count crossings whose both midpoints fall in a cell (or either endpoint).
cell_cross: dict[tuple[int, int], int] = {k: 0 for k in buckets}
for i, (a, b) in enumerate(links):
p1 = (pos[a][0], pos[a][1])
p2 = (pos[b][0], pos[b][1])
for c, d in links[i + 1 :]:
if len({a, b, c, d}) < 4:
continue
p3 = (pos[c][0], pos[c][1])
p4 = (pos[d][0], pos[d][1])
if not segments_properly_intersect(p1, p2, p3, p4):
continue
mx = (p1[0] + p2[0] + p3[0] + p4[0]) / 4
my = (p1[1] + p2[1] + p3[1] + p4[1]) / 4
ck = cell_key(mx, my)
cell_cross[ck] = cell_cross.get(ck, 0) + 1
blocks = []
for (cx, cy), ids in buckets.items():
xs = [pos[i][0] for i in ids]
ys = [pos[i][1] for i in ids]
sample = sorted(ids, key=lambda i: pos[i][2])[:8]
blocks.append(
{
"cell": [cx, cy],
"bbox": [
round(min(xs), 1),
round(min(ys), 1),
round(max(xs), 1),
round(max(ys), 1),
],
"node_count": len(ids),
"crossings_near": cell_cross.get((cx, cy), 0),
"sample_nodes": [
{
"fabric_node_id": i,
"short": _short_name(pos[i][2]),
"x": round(pos[i][0], 1),
"y": round(pos[i][1], 1),
}
for i in sample
],
}
)
blocks.sort(key=lambda b: (-int(b["crossings_near"]), -int(b["node_count"])))
return {
"cell": cell,
"blocks": blocks[:top_k],
"tip": (
"分块手拖:选 crossings_near 高的 block,"
"getTopologyView / queryTopologyNeighborhood 看邻接,"
"再 updateTopologyViewPositions 挪该块内的点。"
),
}
def build_sight(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
mode: str = "hotspots",
limit: int = 40,
cell: float = 600.0,
) -> dict[str, Any]:
mode = (mode or "hotspots").strip().lower()
out: dict[str, Any] = {"mode": mode}
if mode in {"hotspots", "both", "all"}:
out["hotspots"] = list_crossings(nodes, edges, limit=limit)
if mode in {"blocks", "both", "all"}:
out["blocks"] = spatial_blocks(nodes, edges, cell=cell, top_k=min(16, max(6, limit // 3)))
return out