mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(explorer): make distance intelligence API calls slash-safe
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user