mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(explorer): resolve merge-artifact syntax errors and clean up all route files
app.py:
- Fix unclosed '(' in generic_error_handler (two implementations were merged,
leaving the return JSONResponse( call with no closing paren)
- Remove duplicate 'from fastapi import FastAPI, Request' import
- Remove unused 'import traceback'
- Remove duplicate static file mount (was mounted twice: once conditionally,
once unconditionally creating the dir — FastAPI raises on duplicate mounts)
decisions.py:
- Remove stub 'return ComplianceResponse(compliant=True)' with unclosed '('
that was left in front of the real edge-scan implementation
temporal.py:
- Remove blocking get_nodes/get_edges calls (without asyncio.to_thread) that
were left as dead code above the correct async versions
- Fix empty 'except Exception:' clause before 'except ImportError:' that
caused a SyntaxError
tests/explorer/test_explorer_api.py:
- Remove all merge-artifact duplicate class definitions (TestAnalytics x2,
TestReasoning x2, TestAnnotations x2) — Python silently used the second
definition, hiding the first; collapsed into single canonical classes
- Fix test_snapshot_at referencing undefined 'body' (no request was made);
merged its assertions into test_snapshot_now
- Fix test_compliance asserting isinstance(body, list) on a dict response;
the displaced precedents-check code is now in test_precedents where it
belongs
- Fix test_compliance_with_violation using wrong session reference
- Remove duplicate node-lookup and duplicate assertions throughout
- Add test_search_content_populated: asserts search results carry non-empty
content (regression guard for the to_dict envelope fix)
- Add test_import_edge_metadata_preserved: asserts edge metadata survives the
import round-trip (regression guard for the properties/metadata fallback fix)
All 51 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7b6e74d042
commit
ac047f917a
@@ -6,12 +6,10 @@ static file serving, route registration, and WebSocket support.
|
||||
"""
|
||||
|
||||
import os
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -33,12 +31,10 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
if session is not None:
|
||||
app.state.session = session
|
||||
app.state.ws_manager = ConnectionManager()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Semantica Knowledge Explorer",
|
||||
@@ -47,7 +43,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
cors_origins = os.environ.get("EXPLORER_CORS_ORIGINS", "*")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -57,7 +52,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(KeyError)
|
||||
async def key_error_handler(request: Request, exc: KeyError):
|
||||
return JSONResponse(
|
||||
@@ -74,10 +68,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_error_handler(request: Request, exc: Exception):
|
||||
tb = traceback.format_exc()
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": f"Internal Server Error: {exc}", "traceback": tb},
|
||||
# Let FastAPI's built-in HTTPException handler take precedence so that
|
||||
# responses from dependency injection (e.g. 503 from get_session) are
|
||||
# not swallowed and converted to 500.
|
||||
@@ -104,7 +94,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
app.include_router(export_import_router)
|
||||
app.include_router(annotations_router)
|
||||
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
@app.websocket("/ws/graph-updates")
|
||||
@@ -113,12 +102,10 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
await manager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
@@ -131,14 +118,9 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
if static_dir.is_dir():
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
|
||||
return app
|
||||
|
||||
@@ -160,15 +160,6 @@ async def check_compliance(
|
||||
raise KeyError(decision_id)
|
||||
|
||||
|
||||
try:
|
||||
from ...context.policy_engine import PolicyEngine
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return ComplianceResponse(
|
||||
decision_id=decision_id,
|
||||
compliant=True,
|
||||
violations=[],
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
|
||||
_VIOLATION_TYPES = {"violates", "non_compliant", "breaches"}
|
||||
|
||||
@@ -103,8 +103,6 @@ async def temporal_patterns(
|
||||
detector = TemporalPatternDetector()
|
||||
|
||||
|
||||
nodes, _ = session.get_nodes(skip=0, limit=999_999)
|
||||
edges, _ = session.get_edges(skip=0, limit=999_999)
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
graph_dict = {
|
||||
@@ -123,7 +121,6 @@ async def temporal_patterns(
|
||||
if isinstance(patterns, dict):
|
||||
patterns = patterns.get("patterns", [])
|
||||
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
|
||||
except Exception:
|
||||
except ImportError:
|
||||
# TemporalPatternDetector is an optional KG extra; return empty gracefully.
|
||||
return TemporalPatternResponse(patterns=[])
|
||||
|
||||
@@ -22,7 +22,6 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _build_sample_graph() -> ContextGraph:
|
||||
"""Create a small ContextGraph with a handful of nodes and edges."""
|
||||
g = ContextGraph(advanced_analytics=False)
|
||||
@@ -75,8 +74,6 @@ class TestHealthInfo:
|
||||
body = r.json()
|
||||
assert body["name"] == "Semantica Knowledge Explorer"
|
||||
assert "version" in body
|
||||
|
||||
|
||||
assert body["status"] == "active"
|
||||
|
||||
|
||||
@@ -135,10 +132,6 @@ class TestGraphNodes:
|
||||
assert len(body) >= 1
|
||||
ids = [nb["id"] for nb in body]
|
||||
assert "ml" in ids or "web_dev" in ids
|
||||
|
||||
|
||||
|
||||
# Each neighbour must have required fields
|
||||
for nb in body:
|
||||
assert "id" in nb and "type" in nb and "hop" in nb
|
||||
|
||||
@@ -172,10 +165,6 @@ class TestGraphEdges:
|
||||
r = client.get("/api/graph/edges?source=python")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert all(e["source"] == "python" for e in body["edges"])
|
||||
|
||||
|
||||
|
||||
assert len(body["edges"]) >= 1
|
||||
assert all(e["source"] == "python" for e in body["edges"])
|
||||
|
||||
@@ -197,11 +186,21 @@ class TestSearchStats:
|
||||
body = r.json()
|
||||
assert body["query"] == "programming"
|
||||
assert len(body["results"]) >= 1
|
||||
# Each result must carry a node and a score
|
||||
for item in body["results"]:
|
||||
assert "node" in item and "score" in item
|
||||
assert item["node"]["id"] # non-empty id
|
||||
|
||||
def test_search_content_populated(self, client):
|
||||
"""Bug fix: search results must carry non-empty content (not empty string)."""
|
||||
r = client.post("/api/graph/search", json={"query": "programming", "limit": 5})
|
||||
assert r.status_code == 200
|
||||
for item in r.json()["results"]:
|
||||
node = item["node"]
|
||||
assert node.get("content"), (
|
||||
f"Node {node.get('id')!r} has empty content in search result — "
|
||||
"node.to_dict() 'properties' envelope was not normalised"
|
||||
)
|
||||
|
||||
def test_search_no_results(self, client):
|
||||
r = client.post("/api/graph/search", json={"query": "zzznomatchzzz"})
|
||||
assert r.status_code == 200
|
||||
@@ -214,7 +213,6 @@ class TestSearchStats:
|
||||
assert body["node_count"] >= 5
|
||||
assert body["edge_count"] >= 3
|
||||
assert "density" in body
|
||||
|
||||
assert "node_types" in body and "edge_types" in body
|
||||
assert body["density"] >= 0.0
|
||||
|
||||
@@ -243,7 +241,6 @@ class TestDecisions:
|
||||
def test_get_decision(self, client):
|
||||
r = client.get("/api/decisions/decision_1")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["decision_id"] == "decision_1"
|
||||
body = r.json()
|
||||
assert body["decision_id"] == "decision_1"
|
||||
assert body["outcome"] == "approved"
|
||||
@@ -262,16 +259,6 @@ class TestDecisions:
|
||||
def test_precedents(self, client):
|
||||
r = client.get("/api/decisions/decision_1/precedents")
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json(), list)
|
||||
|
||||
def test_compliance(self, client):
|
||||
r = client.get("/api/decisions/decision_1/compliance")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "compliant" in body
|
||||
|
||||
|
||||
|
||||
body = r.json()
|
||||
assert isinstance(body, list)
|
||||
# decision_2 shares category "tech" so should appear
|
||||
@@ -289,8 +276,7 @@ class TestDecisions:
|
||||
|
||||
def test_compliance_with_violation(self, client):
|
||||
"""Add a violation edge then check compliance detects it."""
|
||||
# Add a policy node and a violates edge directly on the underlying graph
|
||||
session = GraphSession(client.app.state.session.graph)
|
||||
session = client.app.state.session
|
||||
session.graph.add_node("policy_1", node_type="policy", content="Data policy")
|
||||
session.graph.add_edge("decision_1", "policy_1", edge_type="violates")
|
||||
|
||||
@@ -301,10 +287,6 @@ class TestDecisions:
|
||||
assert len(body["violations"]) >= 1
|
||||
assert body["violations"][0]["policy_id"] == "policy_1"
|
||||
|
||||
# Clean up — remove the test edge so other tests are not affected
|
||||
# (ContextGraph doesn't expose edge removal; re-create the session fixture
|
||||
# to isolate: this test intentionally runs after all other decision tests)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Temporal
|
||||
@@ -316,8 +298,6 @@ class TestTemporal:
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "active_node_count" in body
|
||||
|
||||
def test_snapshot_at(self, client):
|
||||
assert "timestamp" in body
|
||||
assert isinstance(body["active_nodes"], list)
|
||||
|
||||
@@ -343,16 +323,6 @@ class TestTemporal:
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "added_nodes" in body
|
||||
assert "removed_nodes" in body
|
||||
|
||||
|
||||
|
||||
|
||||
class TestAnalytics:
|
||||
def test_analytics(self, client):
|
||||
r = client.get("/api/analytics")
|
||||
assert r.status_code == 200
|
||||
assert "added_nodes" in body and "removed_nodes" in body
|
||||
# temporal_node became active between t1 and t2
|
||||
assert "temporal_node" in body["added_nodes"]
|
||||
@@ -381,7 +351,6 @@ class TestAnalytics:
|
||||
def test_analytics_select_metric(self, client):
|
||||
r = client.get("/api/analytics?metrics=centrality")
|
||||
assert r.status_code == 200
|
||||
# When KG extras are absent the key is still present but None; otherwise a dict
|
||||
body = r.json()
|
||||
assert "centrality" in body
|
||||
|
||||
@@ -390,11 +359,6 @@ class TestAnalytics:
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "valid" in body
|
||||
|
||||
|
||||
|
||||
class TestReasoning:
|
||||
def test_reason(self, client):
|
||||
assert "error_count" in body and "warning_count" in body
|
||||
assert isinstance(body["issues"], list)
|
||||
|
||||
@@ -413,16 +377,7 @@ class TestReasoning:
|
||||
"mode": "forward",
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 422)
|
||||
|
||||
|
||||
|
||||
|
||||
class TestAnnotations:
|
||||
def test_create_and_list(self, client):
|
||||
|
||||
# 200 when reasoning module is available; 422 when it's not installed.
|
||||
# Either is acceptable — the endpoint must not 500.
|
||||
assert r.status_code in (200, 422), (
|
||||
f"Unexpected status {r.status_code}: {r.text}"
|
||||
)
|
||||
@@ -572,10 +527,8 @@ class TestExport:
|
||||
def test_export_json(self, client):
|
||||
r = client.post("/api/export", json={"format": "json"})
|
||||
assert r.status_code == 200
|
||||
assert "json" in r.headers.get("content-type", "").lower() or len(r.content) > 0
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert "json" in ct.lower()
|
||||
# Payload must be valid JSON with entities and relationships
|
||||
data = r.json()
|
||||
assert "entities" in data and "relationships" in data
|
||||
assert len(data["entities"]) >= 5
|
||||
@@ -592,7 +545,6 @@ class TestExport:
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -614,9 +566,6 @@ class TestImport:
|
||||
assert body["status"] == "success"
|
||||
assert body["nodes_added"] >= 1
|
||||
|
||||
r2 = client.get("/api/graph/node/imported_node")
|
||||
assert r2.status_code == 200
|
||||
# Verify the node is now in the graph
|
||||
r2 = client.get("/api/graph/node/imported_node")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["id"] == "imported_node"
|
||||
@@ -639,6 +588,40 @@ class TestImport:
|
||||
body = r.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["nodes_added"] >= 2
|
||||
assert body["edges_added"] >= 1
|
||||
|
||||
def test_import_edge_metadata_preserved(self, client):
|
||||
"""Bug fix: edge metadata must survive the import round-trip."""
|
||||
payload = json.dumps({
|
||||
"nodes": [
|
||||
{"id": "meta_src", "type": "test", "properties": {"content": "src"}},
|
||||
{"id": "meta_tgt", "type": "test", "properties": {"content": "tgt"}},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source": "meta_src",
|
||||
"target": "meta_tgt",
|
||||
"type": "tagged",
|
||||
"metadata": {"label": "important", "weight": 0.7},
|
||||
}
|
||||
],
|
||||
})
|
||||
r = client.post(
|
||||
"/api/import",
|
||||
files={"file": ("meta_import.json", payload, "application/json")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["edges_added"] >= 1
|
||||
|
||||
# Retrieve the edge and verify metadata survived
|
||||
r2 = client.get("/api/graph/edges?source=meta_src&target=meta_tgt")
|
||||
assert r2.status_code == 200
|
||||
edges = r2.json()["edges"]
|
||||
assert len(edges) >= 1
|
||||
props = edges[0].get("properties", {})
|
||||
assert props.get("label") == "important", (
|
||||
"Edge metadata dropped during import — add_edges() 'properties'/'metadata' fallback not working"
|
||||
)
|
||||
|
||||
def test_import_unsupported_format(self, client):
|
||||
r = client.post(
|
||||
|
||||
Reference in New Issue
Block a user