fix(explorer): make distance intelligence API calls slash-safe

This commit is contained in:
Zohaib Hassnain
2026-04-29 21:17:36 +05:00
parent b6373204e2
commit e3a3f6010b
5 changed files with 291 additions and 58 deletions
@@ -1496,8 +1496,13 @@ export function GraphWorkspace() {
const handleTracePath = useCallback(async () => {
if (!inspectableNodeId || !pathTargetId.trim()) return;
try {
const pathParams = new URLSearchParams({
source: inspectableNodeId,
target: pathTargetId.trim(),
algorithm: "dijkstra",
});
const response = await fetch(
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
`/api/graph/path?${pathParams.toString()}`
);
if (!response.ok) {
throw new Error(`Path lookup failed with status ${response.status}`);
@@ -1667,8 +1672,12 @@ export function GraphWorkspace() {
const loadSemanticNeighborhood = async () => {
try {
const semanticParams = new URLSearchParams({
node_id: distanceAnchorNodeId,
top_k: "50",
});
const response = await fetch(
`/api/graph/node/${encodeURIComponent(distanceAnchorNodeId)}/semantic-neighborhood?top_k=50`,
`/api/graph/semantic-neighborhood?${semanticParams.toString()}`,
);
if (cancelled) {
return;
@@ -1676,6 +1685,8 @@ export function GraphWorkspace() {
if (!response.ok) {
throw new Error(response.status === 503
? "Semantic similarity is unavailable for this graph."
: response.status === 404
? "Selected node was not found by the semantic distance API."
: `Semantic distance failed with status ${response.status}`);
}
@@ -510,8 +510,13 @@ export function GraphWorkspaceShell() {
if (!selectedNodeId || !pathTargetId.trim()) return;
try {
const pathParams = new URLSearchParams({
source: selectedNodeId,
target: pathTargetId.trim(),
algorithm: "dijkstra",
});
const response = await fetch(
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
`/api/graph/path?${pathParams.toString()}`,
);
if (!response.ok) {
throw new Error(`Path lookup failed with status ${response.status}`);
+168 -52
View File
@@ -78,6 +78,60 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
return min_x, min_y, max_x, max_y
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
if isinstance(value, dict):
for key in ("embedding", "vector", "values", "node2vec", "semantic"):
nested = _coerce_embedding_vector(value.get(key))
if nested is not None:
return nested
return None
if not isinstance(value, (list, tuple)):
return None
vector: List[float] = []
for item in value:
try:
vector.append(float(item))
except (TypeError, ValueError):
return None
return vector if vector else None
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
embeddings: dict[str, List[float]] = {}
embedding_keys = (
"embedding",
"embeddings",
"vector",
"node_embedding",
"node2vec_embedding",
"semantic_embedding",
"reasoning_embedding",
)
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
if not isinstance(entity, dict):
continue
node_id = entity.get("id") or entity.get("node_id")
if not node_id:
continue
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
for key in embedding_keys:
vector = _coerce_embedding_vector(
entity.get(key, metadata.get(key, properties.get(key)))
)
if vector is not None:
embeddings[str(node_id)] = vector
break
return embeddings
def _node_response(node: dict) -> NodeResponse:
return NodeResponse(**node)
@@ -183,14 +237,14 @@ class _PathAlgorithm(str, Enum):
@router.get("/node/{node_id}/path", response_model=PathResponse)
async def find_path(
node_id: str,
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
async def _find_path_impl(
source: str,
target: str,
algorithm: _PathAlgorithm,
directed: bool,
session: GraphSession,
) -> PathResponse:
"""Resolve and enrich a path between two arbitrary graph node ids."""
path_finder = session.path_finder
if path_finder is None:
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
@@ -202,13 +256,13 @@ async def find_path(
else path_finder.bfs_shortest_path
)
try:
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
result = await asyncio.to_thread(path_fn, graph_dict, source, target, directed=directed)
except Exception as exc:
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}': {exc}")
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
if not path_nodes:
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}'")
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
@@ -262,7 +316,7 @@ async def find_path(
try:
k_paths = await asyncio.to_thread(
path_finder.find_k_shortest_paths,
graph_dict, node_id, target, hop_count + 2, directed=directed
graph_dict, source, target, hop_count + 2, directed=directed
)
alternative_path_count = max(0, len(k_paths) - 1)
except Exception as exc:
@@ -273,7 +327,7 @@ async def find_path(
try:
sim_result = await asyncio.to_thread(
session.similarity.cosine_similarity,
graph_dict, node_id, target
graph_dict, source, target
)
if isinstance(sim_result, (int, float)):
semantic_similarity = float(sim_result)
@@ -302,7 +356,7 @@ async def find_path(
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
return PathResponse(
source=node_id,
source=source,
target=target,
algorithm=algorithm.value,
path=path_nodes,
@@ -320,6 +374,28 @@ async def find_path(
)
@router.get("/path", response_model=PathResponse)
async def find_path_by_query(
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
return await _find_path_impl(source, target, algorithm, directed, session)
@router.get("/node/{node_id}/path", response_model=PathResponse)
async def find_path(
node_id: str,
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
return await _find_path_impl(node_id, target, algorithm, directed, session)
@router.post("/search", response_model=SearchResultResponse)
async def search_nodes(
body: SearchRequest,
@@ -445,51 +521,71 @@ async def distance_matrix(
)
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
async def semantic_neighborhood(
async def _semantic_neighborhood_impl(
node_id: str,
top_k: int = Query(20, ge=1, le=200),
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
session: GraphSession = Depends(get_session),
):
top_k: int,
min_similarity: float,
session: GraphSession,
) -> SemanticNeighborhoodResponse:
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
similarity = session.similarity
if similarity is None:
raise HTTPException(
status_code=503,
detail="Semantic similarity is unavailable for this graph session.",
)
graph_dict = await asyncio.to_thread(session.build_graph_dict)
embeddings = _extract_node_embeddings(graph_dict)
query_embedding = embeddings.get(node_id)
if not embeddings or query_embedding is None:
raise HTTPException(
status_code=503,
detail="Semantic similarity is unavailable because this graph has no node embeddings.",
)
neighbors: List[SemanticNeighborItem] = []
if session.similarity is not None:
graph_dict = await asyncio.to_thread(session.build_graph_dict)
try:
similar = await asyncio.to_thread(
session.similarity.find_most_similar,
graph_dict, node_id, top_k=top_k * 2
try:
similar = await asyncio.to_thread(
similarity.find_most_similar,
embeddings,
query_embedding,
top_k=top_k * 2,
)
except Exception as exc:
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
raise HTTPException(
status_code=503,
detail="Semantic similarity search failed for this graph session.",
) from exc
# find_most_similar returns list of (node_id, score) or dicts
for item in similar:
if isinstance(item, (list, tuple)) and len(item) >= 2:
nid, sim_score = item[0], item[1]
elif isinstance(item, dict):
nid = item.get("node_id") or item.get("id", "")
sim_score = item.get("similarity", item.get("score", 0.0))
else:
continue
if float(sim_score) < min_similarity or nid == node_id:
continue
neighbor_node = await asyncio.to_thread(session.get_node, nid)
if neighbor_node is None:
continue
neighbors.append(
SemanticNeighborItem(
id=str(nid),
type=neighbor_node.get("type", ""),
content=neighbor_node.get("content", ""),
similarity=float(sim_score),
)
# find_most_similar returns list of (node_id, score) or dicts
for item in similar:
if isinstance(item, (list, tuple)) and len(item) >= 2:
nid, sim_score = item[0], item[1]
elif isinstance(item, dict):
nid = item.get("node_id") or item.get("id", "")
sim_score = item.get("similarity", item.get("score", 0.0))
else:
continue
if float(sim_score) < min_similarity or nid == node_id:
continue
neighbor_node = await asyncio.to_thread(session.get_node, nid)
if neighbor_node is None:
continue
neighbors.append(
SemanticNeighborItem(
id=nid,
type=neighbor_node.get("type", ""),
content=neighbor_node.get("content", ""),
similarity=float(sim_score),
)
)
if len(neighbors) >= top_k:
break
except Exception as exc:
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
)
if len(neighbors) >= top_k:
break
return SemanticNeighborhoodResponse(
anchor_node=node_id,
@@ -498,6 +594,26 @@ async def semantic_neighborhood(
)
@router.get("/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
async def semantic_neighborhood_by_query(
node_id: str = Query(..., description="Anchor node ID"),
top_k: int = Query(20, ge=1, le=200),
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
session: GraphSession = Depends(get_session),
):
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
async def semantic_neighborhood(
node_id: str,
top_k: int = Query(20, ge=1, le=200),
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
session: GraphSession = Depends(get_session),
):
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
@router.get("/stats", response_model=GraphStatsResponse)
async def graph_stats(
session: GraphSession = Depends(get_session),
+5 -3
View File
@@ -136,7 +136,7 @@ def test_sec002_distance_matrix_upper_triangle_loop():
def test_bug006_edge_weight_index_built_once():
from semantica.explorer.routes import graph
src = inspect.getsource(graph.find_path)
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
assert "edge_weight_index" in src
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
@@ -157,7 +157,7 @@ def test_bug007_original_id_not_overwritten():
def test_qual002_no_bare_except_pass_in_find_path():
from semantica.explorer.routes import graph
src = inspect.getsource(graph.find_path)
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
assert "logger.debug" in src
@@ -186,7 +186,9 @@ def test_bug008_sweep_generation_counter():
def test_bug001_semantic_neighborhood_uses_top_k():
with open(TS_WORKSPACE, encoding="utf-8") as fh:
src = fh.read()
assert "top_k=50" in src, "Should use top_k (not limit) to match backend param"
assert (
"top_k=50" in src or 'top_k: "50"' in src
), "Should use top_k (not limit) to match backend param"
idx = src.find("semantic-neighborhood?")
snippet = src[idx: idx + 100]
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
+99
View File
@@ -733,7 +733,10 @@ def _make_path_session() -> GraphSession:
cg = ContextGraph(advanced_analytics=False)
cg.add_node("A", node_type="entity", content="Node A")
cg.add_node("B", node_type="entity", content="Node B")
cg.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34")
cg.add_node("disease/term:1", node_type="disease", content="Slash target")
cg.add_edge("A", "B", edge_type="connects")
cg.add_edge("gene/protein:6164", "disease/term:1", edge_type="connects")
session = GraphSession(cg)
@@ -742,6 +745,7 @@ def _make_path_session() -> GraphSession:
# PathFinder; this mimics how a KG-backed session would expose the graph.
digraph = nx.DiGraph()
digraph.add_edge("A", "B")
digraph.add_edge("gene/protein:6164", "disease/term:1")
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
return session
@@ -792,6 +796,22 @@ class TestBidirectionalPathRoute:
assert body["path"] == ["B", "A"]
assert body["directed"] is False
def test_query_path_route_supports_slash_node_ids(self, path_client):
"""Query-param path route must support arbitrary graph ids with slashes."""
resp = path_client.get(
"/api/graph/path",
params={
"source": "gene/protein:6164",
"target": "disease/term:1",
"algorithm": "dijkstra",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["gene/protein:6164", "disease/term:1"]
assert body["source"] == "gene/protein:6164"
assert body["target"] == "disease/term:1"
def test_directed_false_forward_path_found(self, path_client):
"""directed=false must not break the natural A→B direction."""
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
@@ -865,6 +885,85 @@ class TestBidirectionalPathRoute:
from semantica.utils.helpers import classify_path_distance
class TestSlashSafeDistanceRoutes:
class _FakeSimilarity:
def find_most_similar(self, embeddings, query_embedding, top_k=10):
assert "gene/protein:6164" in embeddings
assert query_embedding == [1.0, 0.0, 0.0]
return [("disease/term:1", 0.74)]
def test_query_semantic_neighborhood_supports_slash_node_ids(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node(
"gene/protein:6164",
node_type="gene/protein",
content="RPL34",
embedding=[1.0, 0.0, 0.0],
)
graph.add_node(
"disease/term:1",
node_type="disease",
content="Slash target",
embedding=[0.7, 0.2, 0.1],
)
session = GraphSession(graph)
session._similarity = self._FakeSimilarity()
app = create_app(session=session)
with TestClient(app) as test_client:
resp = test_client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:6164", "top_k": 50},
)
assert resp.status_code == 200
body = resp.json()
assert body["anchor_node"] == "gene/protein:6164"
assert body["neighbors"][0]["id"] == "disease/term:1"
assert body["neighbors"][0]["similarity"] == 0.74
def test_legacy_semantic_neighborhood_still_works_for_simple_ids(self, client):
client.app.state.session.graph.add_node(
"semantic_anchor",
node_type="entity",
content="Semantic anchor",
embedding=[1.0, 0.0],
)
client.app.state.session.graph.add_node(
"semantic_neighbor",
node_type="entity",
content="Semantic neighbor",
embedding=[0.8, 0.2],
)
client.app.state.session._similarity = self._FakeSimilarity()
client.app.state.session._similarity.find_most_similar = (
lambda embeddings, query_embedding, top_k=10: [("semantic_neighbor", 0.8)]
)
resp = client.get("/api/graph/node/semantic_anchor/semantic-neighborhood?top_k=10")
assert resp.status_code == 200
assert resp.json()["anchor_node"] == "semantic_anchor"
def test_query_semantic_neighborhood_missing_node_returns_404(self, client):
resp = client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:missing"},
)
assert resp.status_code == 404
def test_query_semantic_neighborhood_without_embeddings_returns_503(self):
graph = ContextGraph(advanced_analytics=False)
graph.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34")
session = GraphSession(graph)
session._similarity = self._FakeSimilarity()
app = create_app(session=session)
with TestClient(app) as test_client:
resp = test_client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:6164", "top_k": 50},
)
assert resp.status_code == 503
class TestClassifyDistance:
"""Unit tests covering all four band boundaries."""