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 json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
@@ -27,6 +28,22 @@ from .config import MempalaceConfig
|
||||
from .palace import get_collection as _get_palace_collection
|
||||
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.
|
||||
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
|
||||
_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)
|
||||
|
||||
norm_a = _normalize_wing(wing_a)
|
||||
norm_b = _normalize_wing(wing_b)
|
||||
|
||||
tunnels = []
|
||||
for room, data in nodes.items():
|
||||
wings = data["wings"]
|
||||
if len(wings) < 2:
|
||||
continue
|
||||
|
||||
if wing_a and wing_a not in wings:
|
||||
if norm_a and norm_a not in wings:
|
||||
continue
|
||||
if wing_b and wing_b not in wings:
|
||||
if norm_b and norm_b not in wings:
|
||||
continue
|
||||
|
||||
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"])
|
||||
return tunnels[:50]
|
||||
|
||||
@@ -426,6 +455,9 @@ def create_tunnel(
|
||||
target_wing = _require_name(target_wing, "target_wing")
|
||||
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 = {
|
||||
@@ -466,9 +498,14 @@ def list_tunnels(wing: str = None):
|
||||
Returns tunnels where ``wing`` appears as either source or target
|
||||
(tunnels are symmetric, so either endpoint is a valid filter match).
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing)
|
||||
tunnels = _load_tunnels()
|
||||
if wing:
|
||||
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
|
||||
if norm_wing:
|
||||
tunnels = [
|
||||
t
|
||||
for t in tunnels
|
||||
if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing
|
||||
]
|
||||
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,
|
||||
and optionally fetches the connected drawer content.
|
||||
"""
|
||||
norm_wing = _normalize_wing(wing) or wing
|
||||
tunnels = _load_tunnels()
|
||||
connections = []
|
||||
|
||||
@@ -494,7 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
src = t["source"]
|
||||
tgt = t["target"]
|
||||
|
||||
if src["wing"] == wing and src["room"] == room:
|
||||
if src["wing"] == norm_wing and src["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"direction": "outgoing",
|
||||
@@ -505,7 +543,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
|
||||
"tunnel_id": t["id"],
|
||||
}
|
||||
)
|
||||
elif tgt["wing"] == wing and tgt["room"] == room:
|
||||
elif tgt["wing"] == norm_wing and tgt["room"] == room:
|
||||
connections.append(
|
||||
{
|
||||
"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 col and connections:
|
||||
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()
|
||||
kinds = sorted(t["kind"] for t in tunnels)
|
||||
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