fix(tunnels): normalize wing names in topic tunnel lookup for hyphenated dirs (#1194)
This commit is contained in:
@@ -17,6 +17,7 @@ No external graph DB needed — built from ChromaDB metadata.
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -27,6 +28,22 @@ from .config import MempalaceConfig
|
|||||||
from .palace import get_collection as _get_palace_collection
|
from .palace import get_collection as _get_palace_collection
|
||||||
from .palace import mine_lock
|
from .palace import mine_lock
|
||||||
|
|
||||||
|
logger = logging.getLogger("mempalace_graph")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_wing(wing: str | None) -> str | None:
|
||||||
|
"""Normalize a wing name for consistent lookup.
|
||||||
|
|
||||||
|
``init`` stores wing names with hyphens and spaces replaced by underscores
|
||||||
|
(e.g. ``mempalace_public``). Callers that pass the raw directory name
|
||||||
|
(``mempalace-public``) would silently miss. This helper aligns the lookup
|
||||||
|
key with the stored metadata.
|
||||||
|
"""
|
||||||
|
if wing is None:
|
||||||
|
return None
|
||||||
|
return wing.lower().replace(" ", "_").replace("-", "_")
|
||||||
|
|
||||||
|
|
||||||
# Module-level graph cache with TTL and write-invalidation.
|
# Module-level graph cache with TTL and write-invalidation.
|
||||||
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
|
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
|
||||||
_graph_cache_lock = threading.Lock()
|
_graph_cache_lock = threading.Lock()
|
||||||
@@ -225,15 +242,18 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
|||||||
"""
|
"""
|
||||||
nodes, edges = build_graph(col, config)
|
nodes, edges = build_graph(col, config)
|
||||||
|
|
||||||
|
norm_a = _normalize_wing(wing_a)
|
||||||
|
norm_b = _normalize_wing(wing_b)
|
||||||
|
|
||||||
tunnels = []
|
tunnels = []
|
||||||
for room, data in nodes.items():
|
for room, data in nodes.items():
|
||||||
wings = data["wings"]
|
wings = data["wings"]
|
||||||
if len(wings) < 2:
|
if len(wings) < 2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if wing_a and wing_a not in wings:
|
if norm_a and norm_a not in wings:
|
||||||
continue
|
continue
|
||||||
if wing_b and wing_b not in wings:
|
if norm_b and norm_b not in wings:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tunnels.append(
|
tunnels.append(
|
||||||
@@ -246,6 +266,15 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not tunnels and (wing_a or wing_b):
|
||||||
|
logger.warning(
|
||||||
|
"No tunnels found for wing filter(s): wing_a=%r (normalized=%r), wing_b=%r (normalized=%r)",
|
||||||
|
wing_a,
|
||||||
|
norm_a,
|
||||||
|
wing_b,
|
||||||
|
norm_b,
|
||||||
|
)
|
||||||
|
|
||||||
tunnels.sort(key=lambda x: -x["count"])
|
tunnels.sort(key=lambda x: -x["count"])
|
||||||
return tunnels[:50]
|
return tunnels[:50]
|
||||||
|
|
||||||
@@ -426,6 +455,9 @@ def create_tunnel(
|
|||||||
target_wing = _require_name(target_wing, "target_wing")
|
target_wing = _require_name(target_wing, "target_wing")
|
||||||
target_room = _require_name(target_room, "target_room")
|
target_room = _require_name(target_room, "target_room")
|
||||||
|
|
||||||
|
source_wing = _normalize_wing(source_wing)
|
||||||
|
target_wing = _normalize_wing(target_wing)
|
||||||
|
|
||||||
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
|
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
|
||||||
|
|
||||||
tunnel = {
|
tunnel = {
|
||||||
@@ -466,9 +498,14 @@ def list_tunnels(wing: str = None):
|
|||||||
Returns tunnels where ``wing`` appears as either source or target
|
Returns tunnels where ``wing`` appears as either source or target
|
||||||
(tunnels are symmetric, so either endpoint is a valid filter match).
|
(tunnels are symmetric, so either endpoint is a valid filter match).
|
||||||
"""
|
"""
|
||||||
|
norm_wing = _normalize_wing(wing)
|
||||||
tunnels = _load_tunnels()
|
tunnels = _load_tunnels()
|
||||||
if wing:
|
if norm_wing:
|
||||||
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
|
tunnels = [
|
||||||
|
t
|
||||||
|
for t in tunnels
|
||||||
|
if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing
|
||||||
|
]
|
||||||
return tunnels
|
return tunnels
|
||||||
|
|
||||||
|
|
||||||
@@ -487,6 +524,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
|||||||
Given a location (wing/room), finds all tunnels leading from or to it,
|
Given a location (wing/room), finds all tunnels leading from or to it,
|
||||||
and optionally fetches the connected drawer content.
|
and optionally fetches the connected drawer content.
|
||||||
"""
|
"""
|
||||||
|
norm_wing = _normalize_wing(wing) or wing
|
||||||
tunnels = _load_tunnels()
|
tunnels = _load_tunnels()
|
||||||
connections = []
|
connections = []
|
||||||
|
|
||||||
@@ -494,7 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
|||||||
src = t["source"]
|
src = t["source"]
|
||||||
tgt = t["target"]
|
tgt = t["target"]
|
||||||
|
|
||||||
if src["wing"] == wing and src["room"] == room:
|
if src["wing"] == norm_wing and src["room"] == room:
|
||||||
connections.append(
|
connections.append(
|
||||||
{
|
{
|
||||||
"direction": "outgoing",
|
"direction": "outgoing",
|
||||||
@@ -505,7 +543,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
|||||||
"tunnel_id": t["id"],
|
"tunnel_id": t["id"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif tgt["wing"] == wing and tgt["room"] == room:
|
elif tgt["wing"] == norm_wing and tgt["room"] == room:
|
||||||
connections.append(
|
connections.append(
|
||||||
{
|
{
|
||||||
"direction": "incoming",
|
"direction": "incoming",
|
||||||
@@ -517,6 +555,9 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not connections:
|
||||||
|
logger.warning("No explicit tunnels found for %s/%s", wing, room)
|
||||||
|
|
||||||
# If we have a collection, fetch drawer content for connected items
|
# If we have a collection, fetch drawer content for connected items
|
||||||
if col and connections:
|
if col and connections:
|
||||||
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
|
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
|
||||||
|
|||||||
@@ -329,3 +329,48 @@ class TestTopicTunnels:
|
|||||||
tunnels = palace_graph.list_tunnels()
|
tunnels = palace_graph.list_tunnels()
|
||||||
kinds = sorted(t["kind"] for t in tunnels)
|
kinds = sorted(t["kind"] for t in tunnels)
|
||||||
assert kinds == ["explicit", "topic"]
|
assert kinds == ["explicit", "topic"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHyphenatedWingNormalization:
|
||||||
|
"""Wing names with hyphens or spaces are normalized to underscores on init.
|
||||||
|
|
||||||
|
Tunnel helpers must apply the same normalization at lookup time so that
|
||||||
|
``mempalace-public`` resolves to ``mempalace_public`` and matches the
|
||||||
|
metadata written by ``room_detector_local.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_list_tunnels_filters_hyphenated_wing(self, tmp_path, monkeypatch):
|
||||||
|
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
|
||||||
|
|
||||||
|
assert len(palace_graph.list_tunnels("mempalace-public")) == 1
|
||||||
|
assert len(palace_graph.list_tunnels("mempalace_public")) == 1
|
||||||
|
|
||||||
|
def test_follow_tunnels_matches_hyphenated_wing(self, tmp_path, monkeypatch):
|
||||||
|
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
|
||||||
|
|
||||||
|
by_hyphen = palace_graph.follow_tunnels("mempalace-public", "auth")
|
||||||
|
by_under = palace_graph.follow_tunnels("mempalace_public", "auth")
|
||||||
|
assert len(by_hyphen) == 1
|
||||||
|
assert len(by_under) == 1
|
||||||
|
assert by_hyphen[0]["connected_wing"] == "wing_people"
|
||||||
|
|
||||||
|
def test_create_tunnel_normalizes_wing_names(self, tmp_path, monkeypatch):
|
||||||
|
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
t = palace_graph.create_tunnel("my-project", "src", "your-project", "dst", label="cross")
|
||||||
|
assert t["source"]["wing"] == "my_project"
|
||||||
|
assert t["target"]["wing"] == "your_project"
|
||||||
|
assert len(palace_graph.list_tunnels("my_project")) == 1
|
||||||
|
assert len(palace_graph.list_tunnels("my-project")) == 1
|
||||||
|
|
||||||
|
def test_find_tunnels_warns_on_empty_result(self, tmp_path, monkeypatch, caplog):
|
||||||
|
_use_tmp_tunnel_file(monkeypatch, tmp_path)
|
||||||
|
# No data in collection, so build_graph returns empty nodes
|
||||||
|
with caplog.at_level("WARNING", logger="mempalace_graph"):
|
||||||
|
result = palace_graph.find_tunnels("nonexistent-wing")
|
||||||
|
assert result == []
|
||||||
|
assert "No tunnels found" in caplog.text
|
||||||
|
|||||||
Reference in New Issue
Block a user