mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-11 04:01:32 +00:00
Merge branch 'main' into feat/landing-page-visual-refresh
This commit is contained in:
@@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Added `Space Grotesk` and `IBM Plex Sans` fonts (replacing `Inter`); `JetBrains Mono` used for kickers, badges, and metadata labels.
|
||||
- Added `prefers-reduced-motion` media query suppressing `landing-float` animation and launcher hover transitions.
|
||||
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): replaced invalid `inset-left` CSS property with `inset: 0 0 0 72px` on `.landing-page::before` in the `≤680px` breakpoint; added the same `inset` correction to `.landing-page::after` which was still offset at `88px` after rail narrowing; merged duplicate `.landing-capability-band` CSS rule blocks into one; corrected non-standard `font-weight: 850` to `800` on `.landing-launcher-item-title`; removed unused `eyebrow` field from `LandingAction` type and all data entries; extracted the static 42-dot SVG background array to a module-level `PREVIEW_DOTS` constant to avoid recomputing it on every render.
|
||||
- **Fix: Semantic Distance UI slash-safe node IDs** (issue #514, PR #515 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause** — FastAPI decodes `%2F` before route matching, so node IDs containing `/` (e.g. `gene/protein:6164`) split the path-segment route and return 404. The frontend encoded slashes correctly but they were decoded server-side before the router matched the pattern.
|
||||
- Added slash-safe query-param routes: `GET /api/graph/semantic-neighborhood?node_id=...` and `GET /api/graph/path?source=...&target=...`. Legacy path-segment routes (`/node/{id}/semantic-neighborhood`, `/node/{id}/path`) are kept as deprecated backward-compatible aliases with docstrings documenting the limitation.
|
||||
- Frontend (`GraphWorkspace.tsx`, `GraphWorkspaceShell.tsx`) now builds all distance API calls via `URLSearchParams` so node IDs with slashes or other special characters are never embedded in URL path segments.
|
||||
- `_semantic_neighborhood_impl` now returns HTTP 503 (instead of a silent 200 with zero neighbors) when semantic similarity is unavailable or the graph has no node embeddings, and HTTP 404 only when the anchor node itself does not exist. Frontend error messages updated to distinguish the two cases.
|
||||
- Fixed a pre-existing bug where `find_most_similar` was called with `(graph_dict, node_id_string)` instead of the correct `(embeddings_dict, query_vector)` signature; added `_extract_node_embeddings` and `_coerce_embedding_vector` helpers to build the embeddings dict before the call.
|
||||
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): aligned `_coerce_embedding_vector` inner dict-probe key list (added `"embeddings"`, reordered generic-first) with `_extract_node_embeddings` outer key list; added `TODO` comment on per-session embedding cache; extracted `_FakeSimilarity` test stub to module level to eliminate duplication; rewrote `test_legacy_semantic_neighborhood_still_works_for_simple_ids` as a fully isolated `TestClient` session instead of mutating the shared module-scoped `client` fixture.
|
||||
|
||||
- **Fix: Explorer Distance Intelligence visible rendering** (PR #513 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- Distance Intelligence now renders as a first-class visual state through the Sigma reducer/theme pipeline instead of mutating raw graph attributes directly.
|
||||
|
||||
@@ -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,66 @@ 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):
|
||||
# Probe keys in priority order: generic first, then framework-specific.
|
||||
# Must stay aligned with the top-level keys in _extract_node_embeddings.
|
||||
for key in ("embedding", "embeddings", "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]]:
|
||||
# Top-level keys to probe on each entity (and its metadata/properties dicts).
|
||||
# Priority: generic names first, then KG-extras-specific names.
|
||||
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
|
||||
# TODO: cache this per-session graph revision to avoid re-scanning all nodes on every request.
|
||||
embedding_keys = (
|
||||
"embedding",
|
||||
"embeddings",
|
||||
"vector",
|
||||
"node_embedding",
|
||||
"node2vec_embedding",
|
||||
"semantic_embedding",
|
||||
"reasoning_embedding",
|
||||
)
|
||||
|
||||
embeddings: dict[str, List[float]] = {}
|
||||
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 +243,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 +262,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 +322,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 +333,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 +362,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 +380,34 @@ 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),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/path?source=...&target=...
|
||||
for slash-safe path lookup.
|
||||
"""
|
||||
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 +533,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 +606,32 @@ 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),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/semantic-neighborhood?node_id=...
|
||||
for slash-safe semantic neighborhood lookup.
|
||||
"""
|
||||
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,101 @@ class TestBidirectionalPathRoute:
|
||||
from semantica.utils.helpers import classify_path_distance
|
||||
|
||||
|
||||
class _FakeSimilarity:
|
||||
"""Minimal similarity stub shared by slash-safe distance route tests.
|
||||
|
||||
Expects embeddings keyed on 'gene/protein:6164' with query vector [1, 0, 0]
|
||||
and returns a single neighbor result. Tests that need different behaviour
|
||||
can assign a lambda to instance.find_most_similar after construction.
|
||||
"""
|
||||
|
||||
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 _make_slash_node_session(*, with_embeddings: bool = True) -> GraphSession:
|
||||
"""Return an isolated GraphSession with slash-containing node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
kwargs = {"embedding": [1.0, 0.0, 0.0]} if with_embeddings else {}
|
||||
graph.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34", **kwargs)
|
||||
graph.add_node(
|
||||
"disease/term:1",
|
||||
node_type="disease",
|
||||
content="Slash target",
|
||||
**({"embedding": [0.7, 0.2, 0.1]} if with_embeddings else {}),
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
session._similarity = _FakeSimilarity()
|
||||
return session
|
||||
|
||||
|
||||
class TestSlashSafeDistanceRoutes:
|
||||
def test_query_semantic_neighborhood_supports_slash_node_ids(self):
|
||||
session = _make_slash_node_session(with_embeddings=True)
|
||||
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):
|
||||
"""Legacy path-segment route must still return 200 for slash-free node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node(
|
||||
"semantic_anchor",
|
||||
node_type="entity",
|
||||
content="Semantic anchor",
|
||||
embedding=[1.0, 0.0, 0.0],
|
||||
)
|
||||
graph.add_node(
|
||||
"semantic_neighbor",
|
||||
node_type="entity",
|
||||
content="Semantic neighbor",
|
||||
embedding=[0.8, 0.2, 0.0],
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
fake = _FakeSimilarity()
|
||||
fake.find_most_similar = (
|
||||
lambda embeddings, query_embedding, top_k=10: [("semantic_neighbor", 0.8)]
|
||||
)
|
||||
session._similarity = fake
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_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):
|
||||
session = _make_slash_node_session(with_embeddings=False)
|
||||
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