diff --git a/docs/assets/vis_hg.jpg b/docs/assets/vis_hg.jpg index 6ebefed..6ddced3 100644 Binary files a/docs/assets/vis_hg.jpg and b/docs/assets/vis_hg.jpg differ diff --git a/hyperdb/admin.py b/hyperdb/admin.py index aa3b3d7..43d1fdf 100644 --- a/hyperdb/admin.py +++ b/hyperdb/admin.py @@ -46,13 +46,41 @@ def _preview(data: Any, limit: int = 120) -> str: return text if len(text) <= limit else text[:limit] + "..." +def labels_file(storage_file: Any) -> Path: + """Default location of the display-label mapping: ``.labels.json`` next to the ``.hgdb`` file.""" + return Path(storage_file).with_suffix(".labels.json") + + +def load_labels(storage_file: Any, labels: Any = None) -> Dict[str, Any]: + """Display labels for attribute values and keys, per language. + + Shape: ``{"zh": {"entity_type": {...}, "edge_type": {...}, "key": {...}}, "en": {...}}``, + where ``edge_type`` maps the ``type`` attribute of hyperedges and ``key`` maps attribute names. + ``labels`` may be such a dict or a path to a JSON file; when omitted, ``.labels.json`` + next to the storage file is used if it exists. Labels only change what the console shows. + """ + if isinstance(labels, dict): + return labels + path = Path(labels) if labels is not None else labels_file(storage_file) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + print(f"⚠️ Ignoring label file {path}: {e}") + return {} + return data if isinstance(data, dict) else {} + + class AdminState: """Mutable state shared across requests (the handler is re-created for every request).""" - def __init__(self, hypergraph_db: BaseHypergraphDB): + def __init__(self, hypergraph_db: BaseHypergraphDB, labels: Any = None): self.hypergraph_db = hypergraph_db self.dirty = False self.last_saved: Optional[float] = None + # Explicit labels win; otherwise the label file is re-read on every /info so edits show on reload + self.labels = labels class HypergraphAdminHandler(HypergraphAPIHandler): @@ -237,6 +265,7 @@ def _info(self) -> Dict[str, Any]: "num_e": hg.num_e, "dirty": self.state.dirty, "last_saved": self.state.last_saved, + "labels": load_labels(hg.storage_file, self.state.labels), } def _stats(self) -> Dict[str, Any]: @@ -246,15 +275,18 @@ def _stats(self) -> Dict[str, Any]: v_keys: Counter = Counter() e_keys: Counter = Counter() types: Counter = Counter() + e_types: Counter = Counter() for v in hg.all_v: data = hg.v(v, {}) or {} v_keys.update(data.keys()) if data.get("entity_type"): types[str(data["entity_type"])] += 1 for e in hg.all_e: - e_keys.update((hg.e(e, {}) or {}).keys()) + data = hg.e(e, {}) or {} + e_keys.update(data.keys()) + if data.get("type"): + e_types[str(data["type"])] += 1 - top = sorted(hg.all_v, key=lambda v: hg.degree_v(v), reverse=True)[:10] return { "num_v": hg.num_v, "num_e": hg.num_e, @@ -266,9 +298,9 @@ def _stats(self) -> Dict[str, Any]: "degree_hist": sorted(Counter(degrees).items()), "edge_size_hist": sorted(Counter(sizes).items()), "entity_types": types.most_common(12), + "edge_types": e_types.most_common(12), "vertex_keys": v_keys.most_common(20), "edge_keys": e_keys.most_common(20), - "top_vertices": [{"id": _json_safe(v), "degree": hg.degree_v(v)} for v in top], } def _list_vertices(self, params: Dict[str, str]) -> Dict[str, Any]: @@ -307,6 +339,7 @@ def _list_edges(self, params: Dict[str, str]) -> Dict[str, Any]: hg = self.hypergraph_db search = params.get("search", "").strip().lower() vertex = params.get("vertex") + edge_type = params.get("edge_type", "") sort_by = params.get("sort_by", "size") desc = params.get("sort_order", "desc") == "desc" @@ -315,6 +348,8 @@ def _list_edges(self, params: Dict[str, str]) -> Dict[str, Any]: edges = hg.nbr_e_of_v(self._resolve_v(json.loads(vertex))) rows = [] for e in edges: + if edge_type and str((hg.e(e, {}) or {}).get("type", "")) != edge_type: + continue if search: haystack = " ".join(str(v) for v in e) + " " + _preview(hg.e(e, {}), 10**9) if search not in haystack.lower(): @@ -440,11 +475,11 @@ class _AdminTCPServer(socketserver.TCPServer): class HypergraphAdmin: """Management console server for a hypergraph database.""" - def __init__(self, hypergraph_db: BaseHypergraphDB, host: str = "127.0.0.1", port: int = 8900): + def __init__(self, hypergraph_db: BaseHypergraphDB, host: str = "127.0.0.1", port: int = 8900, labels: Any = None): self.hypergraph_db = hypergraph_db self.host = host self.port = port - self.state = AdminState(hypergraph_db) + self.state = AdminState(hypergraph_db, labels) self.httpd: Optional[socketserver.TCPServer] = None @property @@ -479,6 +514,7 @@ def run_admin( port: int = 8900, open_browser: bool = True, blocking: bool = True, + labels: Any = None, ) -> HypergraphAdmin: """Start the admin console for ``hypergraph_db``. @@ -489,11 +525,13 @@ def run_admin( port: Server port (0 picks a free port) open_browser: Whether to open the console in a browser blocking: Whether to block until Ctrl+C. If False, returns immediately. + labels: Display labels (dict or JSON file path), see :func:`load_labels`. + Defaults to ``.labels.json`` next to the storage file. Returns: HypergraphAdmin instance """ - admin = HypergraphAdmin(hypergraph_db, host=host, port=port) + admin = HypergraphAdmin(hypergraph_db, host=host, port=port, labels=labels) thread = admin.start_server(open_browser=open_browser) if not blocking: return admin diff --git a/hyperdb/base.py b/hyperdb/base.py index 1c6c656..2addc7b 100644 --- a/hyperdb/base.py +++ b/hyperdb/base.py @@ -301,7 +301,14 @@ def draw(self, port: int = 8080, open_browser: bool = True, blocking: bool = Tru return draw_hypergraph(hypergraph_db=self, port=port, open_browser=open_browser, blocking=blocking) - def admin(self, port: int = 8900, open_browser: bool = True, blocking: bool = True, host: str = "127.0.0.1"): + def admin( + self, + port: int = 8900, + open_browser: bool = True, + blocking: bool = True, + host: str = "127.0.0.1", + labels=None, + ): """ Open the web admin console for the current HyperDB instance @@ -310,10 +317,14 @@ def admin(self, port: int = 8900, open_browser: bool = True, blocking: bool = Tr ``open_browser``: Whether to automatically open the browser, defaults to True ``blocking``: Whether to block the main thread, defaults to True. Set to False for non-blocking mode. ``host``: Interface to bind, defaults to 127.0.0.1. The console has no authentication. + ``labels``: Display labels for entity types, hyperedge types and attribute names per language + (dict or JSON file path). Defaults to ``.labels.json`` next to the storage file. Returns: HypergraphAdmin instance """ from .admin import run_admin - return run_admin(hypergraph_db=self, host=host, port=port, open_browser=open_browser, blocking=blocking) + return run_admin( + hypergraph_db=self, host=host, port=port, open_browser=open_browser, blocking=blocking, labels=labels + ) diff --git a/hyperdb/draw.py b/hyperdb/draw.py index 10e8b58..85f42cc 100644 --- a/hyperdb/draw.py +++ b/hyperdb/draw.py @@ -139,11 +139,11 @@ def _get_vertices(self, page: int, page_size: int, search: str, sort_by: str, so # Sort by search score if searching (no degree filtering) vertex_data.sort(key=lambda x: x["score"], reverse=True) elif sort_by == "degree": - # First, separate by degree threshold (degree > 50 goes to the end) - vertex_data.sort(key=lambda x: (x["degree"] > 50, -x["degree"] if sort_order == "desc" else x["degree"])) + # Same degree: by id, so pages stay stable + vertex_data.sort(key=lambda x: str(x["id"])) + vertex_data.sort(key=lambda x: x["degree"], reverse=(sort_order == "desc")) elif sort_by == "id": - # First, separate by degree threshold (degree > 50 goes to the end) - vertex_data.sort(key=lambda x: (x["degree"] > 50, str(x["id"])), reverse=(sort_order == "desc")) + vertex_data.sort(key=lambda x: str(x["id"]), reverse=(sort_order == "desc")) # Remove score from output for v in vertex_data: diff --git a/hyperdb/templates/admin.html b/hyperdb/templates/admin.html index 1321671..02c6b28 100644 --- a/hyperdb/templates/admin.html +++ b/hyperdb/templates/admin.html @@ -4,1101 +4,44 @@ Hypergraph DB Admin + - - - + + + - +
- diff --git a/hyperdb/templates/hypergraph_viewer.html b/hyperdb/templates/hypergraph_viewer.html index 5c269cd..2c56785 100644 --- a/hyperdb/templates/hypergraph_viewer.html +++ b/hyperdb/templates/hypergraph_viewer.html @@ -4,1029 +4,56 @@ Hypergraph Visualization - - - - - - - - - -
- diff --git a/tests/test_admin.py b/tests/test_admin.py index a078cbb..c6902a3 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,4 +1,5 @@ import json +import re import urllib.error import urllib.request @@ -40,6 +41,15 @@ def test_pages_served(server): assert b"Hypergraph Visualization" in res.read() +@pytest.mark.parametrize("path", ["/", "/viewer"]) +def test_pages_load_hypergraph_viz(server, path): + # 管理后台与查看器页面从 unpkg 加载固定版本的 hypergraph-viz(带面板的全局版与样式) + with urllib.request.urlopen(server.url + path) as res: + html = res.read().decode("utf-8") + assert re.search(r"unpkg\.com/hypergraph-viz@\d+\.\d+\.\d+/dist/hypergraph-viz\.explorer\.global\.js", html) + assert re.search(r"unpkg\.com/hypergraph-viz@\d+\.\d+\.\d+/dist/style\.css", html) + + def test_info_and_stats(server): status, info = call(server, "/api/admin/info") assert status == 200 @@ -49,6 +59,11 @@ def test_info_and_stats(server): assert stats["max_degree"] == 2 assert stats["edge_size_hist"] == [[2, 1], [4, 1]] assert stats["entity_types"][0] == ["person", 3] + assert stats["edge_types"] == [] + + server.hypergraph_db.update_e((1, 2), {"type": "social"}) + _, stats = call(server, "/api/admin/stats") + assert stats["edge_types"] == [["social", 1]] def test_list_vertices_and_edges(server): @@ -67,6 +82,10 @@ def test_list_vertices_and_edges(server): _, res = call(server, "/api/admin/edges?vertex=3") assert res["pagination"]["total"] == 1 + server.hypergraph_db.update_e((1, 2), {"type": "social"}) + _, res = call(server, "/api/admin/edges?edge_type=social") + assert [e["size"] for e in res["data"]] == [2] + def test_vertex_crud(server): db = server.hypergraph_db @@ -147,3 +166,55 @@ def test_vertex_search_ranks_id_matches_first(server): call(server, "/api/admin/vertex/upsert", {"create": True, "id": "bobby"}) _, res = call(server, "/api/admin/vertices?search=bob") assert [v["id"] for v in res["data"]] == ["bobby", 2] + + +def test_labels(tmp_path): + db = HypergraphDB(storage_file=tmp_path / "labeled.hgdb") + db.add_v(1, {"entity_type": "person"}) + labels = {"zh": {"entity_type": {"person": "人物"}}} + + # Default: .labels.json next to the storage file, re-read on every /info + admin = HypergraphAdmin(db, port=0) + admin.start_server(open_browser=False) + try: + assert call(admin, "/api/admin/info")[1]["labels"] == {} + (tmp_path / "labeled.labels.json").write_text(json.dumps(labels), encoding="utf-8") + assert call(admin, "/api/admin/info")[1]["labels"] == labels + (tmp_path / "labeled.labels.json").write_text("not json", encoding="utf-8") + assert call(admin, "/api/admin/info")[1]["labels"] == {} + finally: + admin.stop_server() + + # Explicit labels win over the file + explicit = {"en": {"key": {"entity_type": "Kind"}}} + admin = HypergraphAdmin(db, port=0, labels=explicit) + admin.start_server(open_browser=False) + try: + assert call(admin, "/api/admin/info")[1]["labels"] == explicit + finally: + admin.stop_server() + + +def test_viewer_vertices_sorted_by_degree_and_id(tmp_path): + # 查看器左栏的实体列表:按度数、ID 排序,度数高的实体不再被排到最后 + db = HypergraphDB(storage_file=tmp_path / "hub.hgdb") + db.add_v("hub", {}) + for i in range(60): + db.add_v(f"v{i:02d}", {}) + db.add_e(("hub", f"v{i:02d}"), {}) + db.add_e(("v00", "v01"), {}) + admin = HypergraphAdmin(db, port=0) + admin.start_server(open_browser=False) + try: + + def ids(sort_by, sort_order): + url = f"{admin.url}/api/vertices?page_size=100&sort_by={sort_by}&sort_order={sort_order}" + with urllib.request.urlopen(url) as res: + return [v["id"] for v in json.loads(res.read())["data"]] + + assert ids("degree", "desc")[:3] == ["hub", "v00", "v01"] + assert ids("degree", "asc")[-1] == "hub" + assert ids("id", "asc")[0] == "hub" + assert ids("id", "desc")[0] == "v59" + finally: + admin.stop_server()