diff --git a/CHANGELOG.md b/CHANGELOG.md index fff35850..87f59f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Feature: Ontology Hub — Alignments, Health Dashboard & SHACL Studio** (closes #520, part of #517, PR #524, by @KaifAhmad1 @ZohaibHassan16): + - **Alignments tab (`AlignmentsTab`)** — full cross-ontology alignment authoring and review UI: + - Create / edit / delete alignments via a form with source URI, target URI, relation selector (owl:equivalentClass, owl:equivalentProperty, all five skos:*Match relations), confidence slider, provenance, source, and reviewer fields. + - Alignment list with per-row confidence badge, relation badge color-coded by type, provenance chip, and one-click delete. + - Pairwise alignment matrix that renders when two or more ontologies are loaded: scrollable table with ontology-pair cells each showing color-coded relation badges; clicking a badge pre-fills the create/edit form. + - Alignment suggestions via `POST /api/ontology/suggest-alignments` — suggestions ranked by a blended score (0.4 × label similarity + 0.6 × TF-IDF char-ngram embedding cosine similarity); one-click accept pushes a suggestion into the create form. + - Ephemeral storage banner reminding users that alignments are in-memory only. + - All handlers wrapped in `useCallback` to prevent unnecessary child re-renders. + - **Health Dashboard (`HealthTab`)** — per-ontology quality scoring across five dimensions: + - **Completeness** — measures label, definition, and annotation coverage across all classes, properties, and concepts. + - **Consistency** — checks for orphaned properties (missing domain/range), invalid domain/range pointing at non-class nodes, and circular subclass chains. + - **SHACL** — placeholder dimension scoring `0.0 / unavailable` until full SHACL validation is wired; excluded from total average when unavailable so scores are not artificially depressed. + - **Alignment** — O(1) set-lookup coverage of how many classes/concepts have at least one recorded alignment; non-zero after any alignment is added. + - **Documentation** — measures rdfs:comment, skos:definition, and skos:scopeNote coverage. + - Total score computed as a rounded mean of scoreable (non-unavailable) dimensions only. + - Issue list with severity badges (error / warning / info), entity URI chip, and "Fix in Editor" deep-link that switches to the Editor tab and sets the `ontologyEntity` URL param. + - Dynamic grid columns that widen to fit the number of dimensions returned. + - Downloadable JSON health report via a corrected `exportReport` that uses `document.body.appendChild` + `URL.revokeObjectURL` with a `setTimeout` to avoid premature revocation. + - Returns HTTP 404 for ontologies not found in the registry. + - **SHACL Studio (`ShaclStudio`)** — interactive SHACL shape authoring and management: + - Shape generation via `POST /api/ontology/shacl/generate` supporting `permissive`, `standard`, and `strict` quality tiers; returns Turtle-serialized SHACL shapes with a shape count summary. + - Shape library panel listing generated shapes as clickable buttons; selecting a shape extracts just that block from the full Turtle and loads it into the editor for focused editing; "View all" restores the full Turtle. + - Monaco editor for Turtle with a custom Monarch tokenizer covering `@prefix` / `@base` / `a` keywords, SHACL prefixed names (`sh:NodeShape`, `sh:property`, `sh:minCount`, etc.), namespace tokens, IRI literals `<...>`, single- and triple-quoted strings, comments, delimiters, and numeric literals. + - SHACL validation via `POST /api/ontology/shacl/validate` — parses the Turtle with rdflib to catch syntax errors (returns HTTP 422 on empty or syntactically invalid input), then returns `status: "unavailable" / conforms: false` as a stub until a full SHACL engine is integrated; never falsely reports `conforms: true`. + - External graph node focus via `onJumpToNode` prop, with a race-condition fix in `GraphWorkspace` (bypasses stale `focusNode` closure by calling `setSelectedNodeId` directly). + - `beforeMount` callback wrapped in `useCallback` for stable Monaco editor reference. + - **Backend — new API endpoints** (`semantica/explorer/routes/ontology.py`): + - `POST /api/ontology/alignments` — upsert alignment; deterministic UUID5 (`uuid.NAMESPACE_OID`) ID based on source + target + relation ensures idempotency; `created_at` is preserved on update; labels derived from graph node lookup with URI-fragment fallback for external URIs; stored in `app.state.ontology_alignments`. + - `GET /api/ontology/alignments` — list all recorded alignments, optionally filtered by `source_uri` or `target_uri`. + - `DELETE /api/ontology/alignments` — delete alignment by `id`; returns HTTP 404 when the ID is not found. + - `POST /api/ontology/suggest-alignments` — ranked alignment suggestions between two ontologies: token prefilter eliminates zero-Jaccard pairs before SequenceMatcher scoring; TF-IDF char-ngram (2–4) embeddings via `sklearn.TfidfVectorizer` with cosine similarity provide an embedding score; combined score = 0.4 × label + 0.6 × embedding; results sorted descending, capped at `limit`; `_MAX_ENTITIES_PER_SIDE = 500` guards the O(n²) pairwise loop. + - `GET /api/ontology/health` — computes the five-dimension health report described above; `_MAX_ANALYSIS_NODES = 5 000` cap prevents OOM on large graphs. + - `POST /api/ontology/shacl/generate` — generates SHACL NodeShapes for all classes and DatatypeProperty shapes for all properties in the target ontology; `sh:severity` set according to quality tier; Turtle serialised via rdflib. + - `GET /api/ontology/shacl/shapes` — lists parsed shapes from a previously generated SHACL document stored in `app.state.ontology_shacl`. + - `POST /api/ontology/shacl/validate` — validates the submitted `shacl_turtle` for Turtle syntax using `rdflib.Graph().parse()`; rejects empty or syntactically invalid Turtle with HTTP 422; always responds `status: "unavailable" / conforms: false` as a stub. + - **Schemas added** — `OntologyAlignment`, `OntologyAlignmentRequest`, `AlignmentSuggestion`, `AlignmentSuggestRequest`, `HealthDimension`, `HealthIssue`, `OntologyHealthReport`, `ShaclGenerateRequest`, `ShaclGenerateResponse`, `ShaclShape`, `ShaclShapesResponse`, `ShaclValidateRequest`, `ShaclValidateResponse`. + - **Helpers added** — `_label_from_uri()`, `_token_set()`, `_tfidf_embedding_vectors()`, `_cosine_sim()`, `_get_alignment_store()`, `_get_drafts()`, `_get_proposals()`, `_get_versions()`, `_alignment_key()`, `_coerce_alignment()`, `_version_field()`, `_ALIGNMENT_RELATIONS` constant map, `_INGEST_FORMAT_SUFFIXES` constant map. + - **Tests** (`tests/explorer/test_ontology_subissue3.py`) — 14 integration tests covering alignment round-trip, upsert idempotency, external-URI label derivation, 404 on unknown alignment delete, suggestion ranking, embedding similarity, health dimension set and scoring, SHACL unavailable dimension exclusion from total, alignment coverage non-zero after recording, SHACL generate and shapes, SHACL validate unavailable stub, empty-Turtle 422, invalid-Turtle 422, 404 for unknown ontology health; all 14 pass. + - **Feature: Ontology Hub — Visual Ontology Editor, Drafts, Versions & Change Proposals** (closes #519, part of #517, by @KaifAhmad1): - **Visual Ontology Editor tab (`OntologyEditor`)** — @xyflow/react canvas for visual ontology authoring without hand-writing OWL or Turtle. Classes render as nodes, properties as edges. Toolbar provides Add Class, Add Property, Add Individual, Add Restriction, Add Axiom, Auto Layout, and Propose actions. Context menus on nodes (rename, add superclass/subclass, add restriction, mark deprecated, add SKOS metadata, delete with impact count) and edges (change domain/range, toggle functional/symmetric/transitive/reflexive/inverse-functional, add inverse property, delete). Detail panel edits metadata for classes, properties, individuals, and SKOS concepts. All edits are debounced and staged as pending diffs via `PATCH /api/ontology/draft`; nothing commits to the live ontology until proposal publish. - **Versions tab (`VersionsTab`)** — version timeline showing version ID, state (draft/published), author, date, and diff summary. Proposal list with state indicators (draft/proposed/approved/published/rejected). Propose modal with summary input, auto-computed impact analysis using `VersionManager.diff_ontologies()` and `OntologyEngine.validate()`, and SHACL pre-validation using `OntologyEngine.validate_graph()`. Compare action for versions with side-by-side diff modal using `VersionManager.compare_versions()` and `VersionManager.diff_ontologies()`. Approve, Request Changes, Reject, and Publish actions. Publishing calls `VersionManager.create_version()` and promotes the draft diff to the live ontology graph. diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 2713f078..3073b5b4 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -1237,6 +1237,7 @@ export default function App() { const [analyzeView, setAnalyzeView] = useState('reasoning'); const [enrichView, setEnrichView] = useState('import'); const [manageView, setManageView] = useState('lineage'); + const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null); const renderWorkspace = () => { @@ -1284,7 +1285,12 @@ export default function App() { } > }> - {exploreView === 'graph' ? : } + {exploreView === 'graph' ? ( + + ) : } ); @@ -1370,7 +1376,13 @@ export default function App() { compact > }> - + { + setGraphFocusRequest({ nodeId, token: Date.now() }); + setActiveWorkspace('explore'); + setExploreView('graph'); + }} + /> ); diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx index 270fe6c1..8c8475ea 100644 --- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx @@ -1082,7 +1082,12 @@ function collectPluginOverlays( }); } -export function GraphWorkspace() { +interface GraphWorkspaceProps { + externalFocusNodeId?: string; + externalFocusToken?: number; +} + +export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) { const [selectedNodeId, setSelectedNodeId] = useState(""); const [focusedNodeId, setFocusedNodeId] = useState(""); const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState(""); @@ -1141,6 +1146,7 @@ export function GraphWorkspace() { const debouncedTime = useDebounce(scrubberTime, 150); const prevActiveIdsRef = useRef>(new Set()); const sceneRef = useRef(null); + const lastExternalFocusTokenRef = useRef(undefined); const pluginRuntimeRef = useRef(null); const settlingOverlayTimeoutRef = useRef(null); const pluginInteractionStateRef = useRef({ @@ -1440,6 +1446,23 @@ export function GraphWorkspace() { } }, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes + useEffect(() => { + if (!externalFocusNodeId || externalFocusToken == null) return; + if (lastExternalFocusTokenRef.current === externalFocusToken) return; + if (!graphReady || !graph.hasNode(externalFocusNodeId)) return; + + lastExternalFocusTokenRef.current = externalFocusToken; + // Set state directly instead of going through focusNode(), which captures + // a stale viewMode in its closure. setViewMode is called first so the node + // is visible in the full graph before the scene pans to it. + setViewMode("full"); + setSelectedNodeId(externalFocusNodeId); + setSelectedEdgeId(""); + window.setTimeout(() => { + sceneRef.current?.focusNode(externalFocusNodeId); + }, 0); + }, [externalFocusNodeId, externalFocusToken, graphReady]); + const handleEdgeSelect = useCallback((edgeId: string) => { setSelectedEdgeId(edgeId); }, []); diff --git a/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx new file mode 100644 index 00000000..36420899 --- /dev/null +++ b/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { CSSProperties } from "react"; +import { GitMerge, Loader2, Sparkles, Trash2 } from "lucide-react"; +import { + loadAlignments, + loadOntologyRegistry, + removeAlignment, + saveAlignment, + suggestAlignments, +} from "./api"; +import type { AlignmentRelation, AlignmentSuggestion, OntologyAlignment, OntologyEntry } from "./types"; + +const RELATIONS: AlignmentRelation[] = [ + "owl:equivalentClass", + "owl:equivalentProperty", + "skos:exactMatch", + "skos:closeMatch", + "skos:broadMatch", + "skos:narrowMatch", + "skos:relatedMatch", +]; + +const RELATION_COLORS: Record = { + "owl:equivalentClass": "#7ce7d3", + "owl:equivalentProperty": "#7ce7d3", + "skos:exactMatch": "#9ee8d7", + "skos:closeMatch": "#58a6ff", + "skos:broadMatch": "#f2b66d", + "skos:narrowMatch": "#f2b66d", + "skos:relatedMatch": "#d2a8ff", +}; + +export function AlignmentsTab() { + const [registry, setRegistry] = useState([]); + const [alignments, setAlignments] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [sourceOntology, setSourceOntology] = useState(""); + const [targetOntology, setTargetOntology] = useState(""); + const [sourceUri, setSourceUri] = useState(""); + const [targetUri, setTargetUri] = useState(""); + const [relation, setRelation] = useState("skos:exactMatch"); + const [confidence, setConfidence] = useState(0.86); + const [provenance, setProvenance] = useState(""); + const [source, setSource] = useState("Ontology Hub"); + const [reviewer, setReviewer] = useState(""); + const [threshold, setThreshold] = useState(0.68); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const reload = useCallback(async () => { + setError(""); + try { + const [registryData, alignmentData] = await Promise.all([ + loadOntologyRegistry(), + loadAlignments(), + ]); + setRegistry(registryData); + setAlignments(alignmentData); + setSourceOntology((current) => current || registryData[0]?.uri || ""); + setTargetOntology((current) => current || registryData[1]?.uri || registryData[0]?.uri || ""); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not load ontology alignments."); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const relationCounts = useMemo(() => { + const counts = new Map(); + for (const item of alignments) { + counts.set(item.relation, (counts.get(item.relation) ?? 0) + 1); + } + return counts; + }, [alignments]); + + // Pairwise matrix: group alignments by (source_ontology, target_ontology) pair. + const matrix = useMemo(() => { + function ontologyOfUri(uri: string): string { + for (const entry of registry) { + if (uri === entry.uri || uri.startsWith(entry.uri + "#") || uri.startsWith(entry.uri + "/")) { + return entry.uri; + } + } + const hashIdx = uri.lastIndexOf("#"); + if (hashIdx > 0) return uri.substring(0, hashIdx); + const slashIdx = uri.lastIndexOf("/"); + return slashIdx > 0 ? uri.substring(0, slashIdx) : uri; + } + const cells: Map = new Map(); + for (const alignment of alignments) { + const key = `${ontologyOfUri(alignment.source_uri)}|||${ontologyOfUri(alignment.target_uri)}`; + const bucket = cells.get(key) ?? []; + bucket.push(alignment); + cells.set(key, bucket); + } + return { ontologies: registry, cells }; + }, [registry, alignments]); + + const handleSave = useCallback(async () => { + if (!sourceUri.trim() || !targetUri.trim()) { + setError("Provide both source and target entity URIs."); + return; + } + setBusy(true); + setError(""); + try { + await saveAlignment({ + source_uri: sourceUri.trim(), + target_uri: targetUri.trim(), + relation, + confidence, + provenance: provenance || undefined, + source: source || undefined, + reviewer: reviewer || undefined, + }); + setSourceUri(""); + setTargetUri(""); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not save alignment."); + } finally { + setBusy(false); + } + }, [sourceUri, targetUri, relation, confidence, provenance, source, reviewer, reload]); + + const handleSuggest = useCallback(async () => { + setBusy(true); + setError(""); + try { + const data = await suggestAlignments({ + source_ontology_uri: sourceOntology || undefined, + target_ontology_uri: targetOntology || undefined, + threshold, + limit: 40, + }); + setSuggestions(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not suggest alignments."); + } finally { + setBusy(false); + } + }, [sourceOntology, targetOntology, threshold]); + + 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 = useCallback(async (id: string) => { + setBusy(true); + setError(""); + try { + await removeAlignment(id); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not remove alignment."); + } finally { + setBusy(false); + } + }, [reload]); + + return ( +
+
+
+
Alignment Matrix
+

Cross-ontology mappings

+

+ Manage equivalence and SKOS match relations with confidence, provenance, + reviewer context, and label-based suggestions. +

+
+
+ + + +
+
+ + {error ?
{error}
: null} + +
+ Alignments are stored in server memory and are not persisted across restarts. + Export your graph or ontology to preserve recorded mappings. +
+ + {matrix.ontologies.length >= 2 ? ( +
+

Pairwise alignment matrix

+
+ + + + + ))} + + + + {matrix.ontologies.map((row) => ( + + + {matrix.ontologies.map((col) => { + const key = `${row.uri}|||${col.uri}`; + const cellItems = matrix.cells.get(key) ?? []; + const isDiag = row.uri === col.uri; + return ( + + ); + })} + + ))} + +
+ {matrix.ontologies.map((col) => ( + {col.name}
{row.name} + {isDiag ? : cellItems.length ? ( +
+ {cellItems.map((item) => ( + { + setSourceUri(item.source_uri); + setTargetUri(item.target_uri); + setRelation(item.relation); + setConfidence(item.confidence); + setProvenance(item.provenance ?? ""); + }} + > + {item.relation.split(":")[1]} + + ))} +
+ ) : ·} +
+
+

Click a relation badge to load it into the editor below.

+
+ ) : null} + +
+
+

Create or update alignment

+ + setSourceUri(event.target.value)} /> + + setTargetUri(event.target.value)} /> +
+
+ + +
+
+ + setConfidence(Number(event.target.value))} + style={{ width: "100%" }} + /> +
+
+ +