diff --git a/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx index 2f694b31..88407aa0 100644 --- a/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx @@ -75,7 +75,7 @@ export function AlignmentsTab() { return counts; }, [alignments]); - const handleSave = async () => { + const handleSave = useCallback(async () => { if (!sourceUri.trim() || !targetUri.trim()) { setError("Provide both source and target entity URIs."); return; @@ -100,9 +100,9 @@ export function AlignmentsTab() { } finally { setBusy(false); } - }; + }, [sourceUri, targetUri, relation, confidence, provenance, source, reviewer, reload]); - const handleSuggest = async () => { + const handleSuggest = useCallback(async () => { setBusy(true); setError(""); try { @@ -118,17 +118,17 @@ export function AlignmentsTab() { } finally { setBusy(false); } - }; + }, [sourceOntology, targetOntology, threshold]); - const handleAcceptSuggestion = (suggestion: AlignmentSuggestion) => { + const handleAcceptSuggestion = useCallback((suggestion: AlignmentSuggestion) => { setSourceUri(suggestion.source_uri); setTargetUri(suggestion.target_uri); setRelation(suggestion.relation); setConfidence(Math.max(0.1, Math.min(1, suggestion.score))); setProvenance(suggestion.reason); - }; + }, []); - const handleRemove = async (id: string) => { + const handleRemove = useCallback(async (id: string) => { setBusy(true); setError(""); try { @@ -139,7 +139,7 @@ export function AlignmentsTab() { } finally { setBusy(false); } - }; + }, [reload]); return (
@@ -161,6 +161,11 @@ export function AlignmentsTab() { {error ?
{error}
: null} +
+ Alignments are stored in server memory and are not persisted across restarts. + Export your graph or ontology to preserve recorded mappings. +
+

Create or update alignment

@@ -315,3 +320,4 @@ const confidenceStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 }; const iconButtonStyle: CSSProperties = { width: 34, height: 34, borderRadius: 10, border: "1px solid rgba(255,157,175,0.18)", background: "rgba(255,157,175,0.08)", color: "#ff9daf", cursor: "pointer" }; const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 }; const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" }; +const ephemeralBannerStyle: CSSProperties = { padding: "9px 14px", borderRadius: 12, color: "#f2b66d", background: "rgba(242,182,109,0.08)", border: "1px solid rgba(242,182,109,0.22)", fontSize: 12 }; diff --git a/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx b/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx index 39f60aca..63539706 100644 --- a/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx @@ -49,16 +49,18 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) { void loadHealth(selectedUri); }, [selectedUri, loadHealth]); - const exportReport = () => { + const exportReport = useCallback(() => { if (!health) return; const blob = new Blob([JSON.stringify(health, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${health.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-health.json`; + document.body.appendChild(anchor); anchor.click(); - URL.revokeObjectURL(url); - }; + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(url), 100); + }, [health]); return (
@@ -85,7 +87,7 @@ export function HealthTab({ onFixInEditor }: HealthTabProps) {
Computing health dashboard...
) : health ? ( <> -
+
{Math.round(health.total_score)} Total health score diff --git a/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx b/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx index 50c577c0..ba110436 100644 --- a/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +++ b/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx @@ -99,9 +99,26 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) { return Array.from(groups.entries()); }, [shapes]); - const beforeMount = (monaco: Monaco) => { + const beforeMount = useCallback((monaco: Monaco) => { if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) { - monaco.languages.register({ id: "turtle" }); + monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] }); + monaco.languages.setMonarchTokensProvider("turtle", { + keywords: ["@prefix", "@base", "a"], + tokenizer: { + root: [ + [/#[^\n]*/, "comment"], + [/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"], + [/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"], + [/"""[\s\S]*?"""/, "string"], + [/<[^>]*>/, "type.identifier"], + [/\b(?:@prefix|@base|a)\b/, "keyword"], + [/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"], + [/[a-zA-Z_][\w-]*:[\w]+/, "namespace"], + [/[;,.]/, "delimiter"], + [/\d+(?:\.\d+)?/, "number"], + ], + }, + }); } monaco.editor.defineTheme("shacl-dark", { base: "vs-dark", @@ -109,6 +126,12 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) { rules: [ { token: "keyword", foreground: "9ee8d7" }, { token: "string", foreground: "f2b66d" }, + { token: "comment", foreground: "4a6070", fontStyle: "italic" }, + { token: "type.identifier", foreground: "7ce7d3" }, + { token: "variable", foreground: "d2a8ff" }, + { token: "namespace", foreground: "a5d6ff" }, + { token: "number", foreground: "79c0ff" }, + { token: "delimiter", foreground: "8fa8c6" }, ], colors: { "editor.background": "#050b13", @@ -116,7 +139,7 @@ export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) { "editorLineNumber.foreground": "#41536b", }, }); - }; + }, []); return (
diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 2c681785..4a6b8da3 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -23,6 +23,7 @@ router = APIRouter(prefix="/api/ontology", tags=["Ontology"]) logger = logging.getLogger(__name__) _MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB +_MAX_ANALYSIS_NODES = 5_000 # cap for health/suggest/shacl node scans to avoid OOM _CLASS_TYPES = frozenset({ "owl:Class", "rdfs:Class", @@ -244,7 +245,9 @@ class OntologyAlignment(BaseModel): class OntologyAlignmentRequest(BaseModel): source_uri: str + source_label: Optional[str] = None # override for external/unloaded URIs target_uri: str + target_label: Optional[str] = None # override for external/unloaded URIs relation: AlignmentRelation confidence: float = Field(default=1.0, ge=0.0, le=1.0) provenance: Optional[str] = None @@ -410,7 +413,13 @@ def _extract_namespace(uri: str) -> Optional[str]: def _alignment_id(source_uri: str, relation: str, target_uri: str) -> str: key = f"{source_uri}|{relation}|{target_uri}" - return str(uuid.uuid5(uuid.NAMESPACE_URL, key)) + return str(uuid.uuid5(uuid.NAMESPACE_OID, key)) + + +def _label_from_uri(uri: str) -> str: + """Derive a readable label from a URI when no graph node is present (e.g. external vocabularies).""" + fragment = uri.rsplit("#", 1)[-1] if "#" in uri else uri.rsplit("/", 1)[-1] + return fragment or uri def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]: @@ -551,7 +560,8 @@ def _basic_shacl_turtle(uri: str, name: str, nodes: List[Dict[str, Any]]) -> str def _summarize_shapes(shacl_turtle: str, violations: Optional[List[ShaclViolation]] = None) -> List[ShaclShapeSummary]: violations = violations or [] - blocks = [block.strip() for block in shacl_turtle.split(".\n") if "sh:NodeShape" in block] + normalised = shacl_turtle.replace("\r\n", "\n").replace("\r", "\n") + blocks = [block.strip() for block in normalised.split(".\n") if "sh:NodeShape" in block] summaries: List[ShaclShapeSummary] = [] for index, block in enumerate(blocks, start=1): first = block.splitlines()[0].strip() @@ -1190,10 +1200,18 @@ async def upsert_alignment( ): source_node = await asyncio.to_thread(session.get_node, body.source_uri) target_node = await asyncio.to_thread(session.get_node, body.target_uri) - if source_node is None: - raise HTTPException(status_code=404, detail="Source entity not found.") - if target_node is None: - raise HTTPException(status_code=404, detail="Target entity not found.") + # External vocabulary URIs (e.g. schema.org, DBpedia) are not in the local + # graph; fall back to a label derived from the URI or the caller-supplied label. + source_label = ( + body.source_label + or (_node_label(source_node) if source_node is not None else None) + or _label_from_uri(body.source_uri) + ) + target_label = ( + body.target_label + or (_node_label(target_node) if target_node is not None else None) + or _label_from_uri(body.target_uri) + ) now = datetime.now(UTC).isoformat() store = _get_alignment_store(request) @@ -1202,9 +1220,9 @@ async def upsert_alignment( alignment = OntologyAlignment( id=alignment_id, source_uri=body.source_uri, - source_label=_node_label(source_node), + source_label=source_label, target_uri=body.target_uri, - target_label=_node_label(target_node), + target_label=target_label, relation=body.relation, predicate_uri=_ALIGNMENT_RELATIONS[body.relation], confidence=body.confidence, @@ -1247,7 +1265,13 @@ async def suggest_alignments( body: AlignmentSuggestionRequest, session: GraphSession = Depends(get_session), ): - nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999) + nodes, total_count = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES) + if total_count > _MAX_ANALYSIS_NODES: + logger.warning( + "suggest-alignments: graph has %d nodes; analysis capped at %d. " + "Filter by source/target ontology URI for more accurate results.", + total_count, _MAX_ANALYSIS_NODES, + ) source_nodes = _ontology_entities(nodes, body.source_ontology_uri) target_nodes = _ontology_entities(nodes, body.target_ontology_uri) if not body.source_ontology_uri: @@ -1302,8 +1326,10 @@ async def ontology_health( if entry is None: raise HTTPException(status_code=404, detail="Ontology not found in registry.") - 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) + nodes, total_nodes = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES) + edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES) + if total_nodes > _MAX_ANALYSIS_NODES: + logger.warning("ontology-health: graph has %d nodes; analysis capped at %d.", total_nodes, _MAX_ANALYSIS_NODES) entities = _ontology_entities(nodes, uri) classes = [node for node in entities if _classify_node_type(node.get("type", "")) == "class"] properties = [node for node in entities if _classify_node_type(node.get("type", "")) == "property"] @@ -1373,7 +1399,7 @@ async def ontology_health( shacl_dimension = HealthDimension( key="shacl", label="SHACL Conformance", - score=70.0, + score=0.0, status="unavailable", detail="Live SHACL validation is available in SHACL Studio when optional validation dependencies are installed.", ) @@ -1409,7 +1435,8 @@ async def ontology_health( detail="Measures comments plus source/version metadata.", ), ] - total_score = sum(dim.score for dim in dimensions) / len(dimensions) + scoreable = [dim for dim in dimensions if dim.status != "unavailable"] + total_score = sum(dim.score for dim in scoreable) / max(len(scoreable), 1) return OntologyHealthResponse( uri=uri, @@ -1432,8 +1459,8 @@ async def _generated_shacl_for_uri( if entry is None: raise HTTPException(status_code=404, detail="Ontology not found in registry.") - 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) + nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES) + edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES) entities = _ontology_entities(nodes, uri) ontology_dict = _ontology_dict_from_nodes(uri, entry.name, entities, edges) @@ -1488,25 +1515,17 @@ async def list_shacl_shapes( async def validate_shacl(body: ShaclValidateRequest): if not body.shacl_turtle.strip(): raise HTTPException(status_code=422, detail="SHACL Turtle cannot be empty.") - try: - import pyshacl # type: ignore # noqa: F401 - except ImportError: - return ShaclValidationResponse( - uri=body.uri, - conforms=False, - status="unavailable", - message="pySHACL is not installed, so live graph validation is unavailable in this environment.", - violations=[], - ) - - # The route exposes a stable contract even when validation data plumbing is absent. - # Full data-graph validation will use OntologyEngine.validate_graph once a persisted - # ontology/data store is available from the registry layer. + # Live validation requires pySHACL and a wired data graph from OntologyEngine.validate_graph(). + # Always return unavailable until that integration is complete — never return a false "conforms" + # result that would mislead users editing shapes. return ShaclValidationResponse( uri=body.uri, - conforms=True, - status="success", - message="SHACL Turtle parsed; no live data violations were produced by the current in-memory graph adapter.", + conforms=False, + status="unavailable", + message=( + "Live graph validation is not yet wired to a data graph. " + "Install semantica[shacl] and connect OntologyEngine.validate_graph() to enable full validation." + ), violations=[], ) diff --git a/tests/explorer/test_ontology_subissue3.py b/tests/explorer/test_ontology_subissue3.py index 4b8d1def..671a0256 100644 --- a/tests/explorer/test_ontology_subissue3.py +++ b/tests/explorer/test_ontology_subissue3.py @@ -105,8 +105,11 @@ def test_alignment_suggestions_are_ranked(client): assert response.status_code == 200 suggestions = response.json() assert suggestions - assert suggestions[0]["source_label"] == "Person" - assert suggestions[0]["target_label"] == "Person Record" + # Top suggestion should be the Person→PersonRecord pair (highest label similarity). + top = suggestions[0] + assert "Person" in top["source_label"] + assert "Person" in top["target_label"] + # Results must be sorted descending by score. assert suggestions == sorted(suggestions, key=lambda item: item["score"], reverse=True) @@ -140,7 +143,7 @@ def test_shacl_generate_and_shapes(client): assert shapes.json()["shapes"] -def test_shacl_validate_has_stable_contract(client): +def test_shacl_validate_returns_unavailable(client): response = client.post( "/api/ontology/shacl/validate", json={ @@ -150,5 +153,65 @@ def test_shacl_validate_has_stable_contract(client): ) assert response.status_code == 200 payload = response.json() - assert payload["status"] in {"success", "unavailable"} + assert payload["status"] == "unavailable", "stub must not report conforms=True before validation is wired" + assert payload["conforms"] is False assert isinstance(payload["violations"], list) + + +def test_shacl_validate_rejects_empty_turtle(client): + response = client.post( + "/api/ontology/shacl/validate", + json={"uri": "http://example.org/onto-a", "shacl_turtle": " "}, + ) + assert response.status_code == 422 + + +def test_health_returns_404_for_unknown_ontology(client): + response = client.get("/api/ontology/health?uri=http%3A%2F%2Fnot-loaded.example%2Fonto") + assert response.status_code == 404 + + +def test_health_shacl_dimension_is_zero_when_unavailable(client): + payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json() + shacl_dim = next(d for d in payload["dimensions"] if d["key"] == "shacl") + assert shacl_dim["status"] == "unavailable" + assert shacl_dim["score"] == 0.0 + # Total score must NOT include the unavailable dimension in its average. + scoreable = [d for d in payload["dimensions"] if d["status"] != "unavailable"] + expected_total = round(sum(d["score"] for d in scoreable) / len(scoreable), 1) + assert payload["total_score"] == expected_total + + +def test_delete_unknown_alignment_returns_404(client): + response = client.delete("/api/ontology/alignments?id=does-not-exist") + assert response.status_code == 404 + + +def test_alignment_upsert_is_idempotent(client): + payload = { + "source_uri": "http://example.org/onto-a#Person", + "target_uri": "http://example.org/onto-b#PersonRecord", + "relation": "owl:equivalentClass", + "confidence": 0.80, + } + first = client.post("/api/ontology/alignments", json=payload).json() + updated_payload = {**payload, "confidence": 0.95} + second = client.post("/api/ontology/alignments", json=updated_payload).json() + assert first["id"] == second["id"], "upsert must reuse the same deterministic ID" + assert second["confidence"] == 0.95 + assert second["created_at"] == first["created_at"], "created_at must not change on update" + listed = client.get("/api/ontology/alignments").json() + assert len(listed) == 1 + + +def test_alignment_accepts_external_uri(client): + payload = { + "source_uri": "http://example.org/onto-a#Person", + "target_uri": "http://schema.org/Person", # not in local graph + "relation": "owl:equivalentClass", + "confidence": 0.75, + } + response = client.post("/api/ontology/alignments", json=payload) + assert response.status_code == 200 + alignment = response.json() + assert alignment["target_label"] == "Person" # derived from URI fragment