diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index 1532eb94..270fe6c1 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -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}`); } diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx index 031558ce..1407bedc 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx @@ -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}`); diff --git a/semantica/explorer/routes/graph.py b/semantica/explorer/routes/graph.py index 9e9badaa..90ffe80f 100644 --- a/semantica/explorer/routes/graph.py +++ b/semantica/explorer/routes/graph.py @@ -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), diff --git a/tests/_smoke_review_fixes.py b/tests/_smoke_review_fixes.py index 9c129909..016e100c 100644 --- a/tests/_smoke_review_fixes.py +++ b/tests/_smoke_review_fixes.py @@ -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}" diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py index d420fcb7..7bca4caa 100644 --- a/tests/explorer/test_explorer_api.py +++ b/tests/explorer/test_explorer_api.py @@ -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."""