diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91dbe2d0..fff35850 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,13 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- **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.
+ - **Proposal Review (`ProposalReview`)** — diff viewer showing added, removed, and modified elements with change icons. Impact analysis display from `VersionManager.diff_ontologies()`. SHACL validation results from `OntologyEngine.validate_graph()`. Inline comments per changed element via `POST /api/ontology/proposals/{id}/comment`. Review actions: Approve, Reject, Publish (approved proposals only).
+ - **Backend (`semantica/explorer/routes/ontology.py`)** — 10 new FastAPI endpoints under `/api/ontology`:
+ - `PATCH /draft` — stage editor diffs as a draft in `app.state.ontology_drafts` with `ChangeLogEntry` audit trail.
+ - `GET /drafts/{ontology_uri:path}` — list staged drafts for an ontology.
+ - `GET /draft/{draft_id}` — get a specific draft by ID.
+ - `POST /propose` — submit change proposal with impact analysis using `VersionManager.diff_ontologies()`, validation using `OntologyEngine.validate()`, and SHACL pre-validation using `OntologyEngine.validate_graph()`.
+ - `GET /proposals` — list proposals with optional `ontology_uri` and `state` filters.
+ - `GET /proposals/{proposal_id}` — get proposal detail.
+ - `POST /proposals/{proposal_id}/approve` — approve a proposal.
+ - `POST /proposals/{proposal_id}/reject` — reject a proposal (can return to draft).
+ - `POST /proposals/{proposal_id}/publish` — publish an approved proposal using `VersionManager.create_version()`.
+ - `POST /proposals/{proposal_id}/comment` — add inline comment to a proposal.
+ - `GET /versions/{ontology_uri:path}` — list version history for an ontology.
+ - `POST /versions/{ontology_uri:path}/compare` — compare two ontology versions using `VersionManager.compare_versions()` and `VersionManager.diff_ontologies()`.
+ - `POST /alignments` — create alignment using `OntologyEngine.create_alignment()`.
+ - `GET /alignments/{entity_uri:path}` — get alignments for an entity using `OntologyEngine.get_alignments()`.
+ - `GET /alignments` — list all alignments using `OntologyEngine.list_alignments()`.
+ - **Enhanced existing endpoints** with proper Semantica module integration:
+ - `POST /load` — now uses `OntologyIngestor.ingest_ontology()` for proper RDF parsing and conversion to Semantica's internal ontology format, with fallback to basic parsing. Supports multiple formats (Turtle, RDF/XML, JSON-LD, N3, NT) with automatic format detection and conversion to graph nodes/edges.
+ - `POST /create` — now uses `OntologyEngine.from_data()` for sample data mode and `OntologyEngine.from_text()` for text mode with provider/model support. Converts OntologyEngine result to graph nodes/edges including classes, properties with domain/range edges, and subclass relationships.
+ - `GET /skos/schemes` — now uses `OntologyEngine.list_vocabularies()` with fallback to session-based implementation.
+ - `GET /skos/concept/{uri:path}` — now uses `OntologyEngine.list_concepts()` with fallback to session-based implementation.
+ - `POST /skos/search` — new endpoint using `OntologyEngine.search_concepts()` with fallback to session-based search.
+ - **Change Management module integration**:
+ - `VersionManager.diff_ontologies()` — structured diff computation for proposals and version comparison.
+ - `VersionManager.compare_versions()` — metadata comparison for version comparison.
+ - `VersionManager.create_version()` — version record creation with proper config and graph store.
+ - `ChangeLogEntry` — audit trail metadata for draft creation with timestamp, author, and description validation.
+ - **Server registration** — ontology router registered in `semantica/server.py` imports and router mounting.
+ - **Schemas added** — `DraftDiff`, `DraftRequest`, `DraftResponse`, `ProposalRequest`, `ProposalResponse`, `CommentRequest`, `VersionEntry`, `VersionCompareRequest`, `VersionCompareResponse`, `AlignmentRequest`, `AlignmentResponse`, `SKOSConceptSearchRequest`.
+ - **OntologyWorkspace index.tsx** — updated to use `OntologyEditor` and `VersionsTab` components instead of stubs for Editor and Versions tabs.
+
- **Fix: Ontology Hub post-review bug fixes and security hardening** (follow-up to #518, closes security advisory #23, by @KaifAhmad1):
- **Broken registry filters** — `fetchRegistry` was sending toolbar filter values (`owl`, `skos`, `internal`, `external`) to the backend as the `status` query param, which only accepts `published|draft|external`, causing those filters to return empty lists. Removed the spurious `status` param; all format/kind filtering is now applied client-side via `filteredEntries`, which already had the correct logic.
- **Toggle/refresh URI corruption** — `toggle_ontology` and `refresh_ontology` applied `.removesuffix("/toggle")` / `.removesuffix("/refresh")` to the captured path parameter, which would silently corrupt any ontology URI that legitimately ends with those strings. Starlette's route regex (`/{uri:path}/toggle`) already strips the literal suffix via backtracking, so the `removesuffix` calls were removed and the raw `ontology_uri` parameter is used directly.
- **SSRF in URL fetch** — `_fetch_url_sync()` accepted arbitrary user-supplied URLs and called `requests.get()` with no validation, enabling server-side request forgery against internal services. Added `_validate_fetch_url()` which rejects non-`http`/`https` schemes and resolves the hostname via `socket.getaddrinfo`, blocking loopback, private, link-local, reserved, and multicast addresses. Applied to all three fetch sites: preview, load, and refresh.
- **File upload format misdetected** — the file picker accepted `.xml` and `.json` but `fmtMap` had no entries for those extensions, causing them to default to `turtle`. Added `xml: "xml"` and `json: "json-ld"` mappings. Changed the unknown-extension fallback from `|| "turtle"` to `?? ""` (empty string), and omit the `format` key from the request body when empty so the backend `_detect_format()` runs instead of receiving a forced incorrect value. Also added `.n3` to the accepted extension list and dropzone hint.
- - **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent protection for all RDF/XML parse paths.
- - **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the `GraphSearchIndex`; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit.
+ - **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent defusedxml-based XXE protection for all RDF/XML parse paths.
+ - **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the existing `GraphSearchIndex` for efficient indexed search; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit.
- **ReDoS in format detector** (security advisory #23, CodeQL `py/polynomial-redos`, CWE-1333/730/400) — `_detect_format()` used `re.match(r"_:\w+|<[^>]+>\s+<[^>]+>", ...)` to detect N-Triples content. The `<[^>]+>\s+<[^>]+>` alternative was flagged as a polynomial regular expression on uncontrolled data. The URI-subject branch was already unreachable (strings starting with `<` return `"xml"` two lines above), so the entire regex was replaced with two O(1) string operations: `stripped.startswith("_:")` and `" <" in stripped`. `import re` removed as now unused.
- **Feature: Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager** (closes #518, part of #517, by @KaifAhmad1):
diff --git a/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx b/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx
new file mode 100644
index 00000000..69643dd0
--- /dev/null
+++ b/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx
@@ -0,0 +1,438 @@
+import { useCallback, useEffect, useState, useMemo } from "react";
+import {
+ ReactFlow,
+ Background,
+ Controls,
+ MiniMap,
+ addEdge,
+ useNodesState,
+ useEdgesState,
+ Connection,
+ Edge,
+ Node,
+ MarkerType,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import {
+ Plus,
+ Box,
+ GitBranch,
+ User,
+ Shield,
+ FileText,
+ Download,
+ Layout,
+ Send,
+ MoreVertical,
+ Pencil,
+ Trash2,
+ Link,
+ ArrowRight,
+ ArrowLeft,
+ GitMerge,
+} from "lucide-react";
+
+const nodeTypes = {
+ classNode: ({ data }: { data: any }) => (
+
+
{data.label}
+
{data.type}
+
+ ),
+};
+
+const classNodeStyle: React.CSSProperties = {
+ padding: "12px 16px",
+ borderRadius: "8px",
+ background: "linear-gradient(135deg, rgba(74, 163, 255, 0.15), rgba(74, 163, 255, 0.05))",
+ border: "1px solid rgba(127, 208, 255, 0.3)",
+ color: "#ebf3ff",
+ fontSize: "13px",
+ fontWeight: "600",
+ minWidth: "140px",
+ textAlign: "center",
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
+};
+
+const classNodeHeader: React.CSSProperties = {
+ fontSize: "14px",
+ fontWeight: "700",
+ marginBottom: "4px",
+};
+
+const classNodeSub: React.CSSProperties = {
+ fontSize: "11px",
+ color: "#8fa8c6",
+ fontWeight: "500",
+};
+
+interface DraftDiff {
+ added_classes: string[];
+ removed_classes: string[];
+ modified_classes: Record>;
+ added_properties: string[];
+ removed_properties: string[];
+ modified_properties: Record>;
+ added_restrictions: Record[];
+ removed_restrictions: Record[];
+ added_axioms: Record[];
+ removed_axioms: Record[];
+ annotation_changes: Record>;
+}
+
+export function OntologyEditor() {
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [selectedElement, setSelectedElement] = useState(null);
+ const [ontologyUri, setOntologyUri] = useState("");
+ const [draftDiff, setDraftDiff] = useState({
+ added_classes: [],
+ removed_classes: [],
+ modified_classes: {},
+ added_properties: [],
+ removed_properties: [],
+ modified_properties: {},
+ added_restrictions: [],
+ removed_restrictions: [],
+ added_axioms: [],
+ removed_axioms: [],
+ annotation_changes: {},
+ });
+ const [isSaving, setIsSaving] = useState(false);
+ const [showContext, setShowContext] = useState<{ x: number; y: number; type: string; element: any } | null>(null);
+
+ const onConnect = useCallback(
+ (params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
+ [setEdges]
+ );
+
+ const addClass = useCallback(() => {
+ const newId = `class_${Date.now()}`;
+ const newNode: Node = {
+ id: newId,
+ type: "classNode",
+ position: { x: Math.random() * 400, y: Math.random() * 300 },
+ data: { label: "NewClass", type: "owl:Class" },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ setDraftDiff((prev) => ({
+ ...prev,
+ added_classes: [...prev.added_classes, newId],
+ }));
+ }, [setNodes]);
+
+ const addProperty = useCallback(() => {
+ const newId = `prop_${Date.now()}`;
+ const newEdge: Edge = {
+ id: newId,
+ source: nodes[0]?.id || "",
+ target: nodes[1]?.id || nodes[0]?.id || "",
+ label: "hasProperty",
+ type: "smoothstep",
+ animated: true,
+ };
+ setEdges((eds) => [...eds, newEdge]);
+ setDraftDiff((prev) => ({
+ ...prev,
+ added_properties: [...prev.added_properties, newId],
+ }));
+ }, [nodes, setEdges]);
+
+ const addIndividual = useCallback(() => {
+ const newId = `ind_${Date.now()}`;
+ const newNode: Node = {
+ id: newId,
+ type: "classNode",
+ position: { x: Math.random() * 400, y: Math.random() * 300 },
+ data: { label: "NewIndividual", type: "owl:NamedIndividual" },
+ };
+ setNodes((nds) => [...nds, newNode]);
+ }, [setNodes]);
+
+ const addRestriction = useCallback(() => {
+ setDraftDiff((prev) => ({
+ ...prev,
+ added_restrictions: [...prev.added_restrictions, { type: "someValuesFrom", value: "" }],
+ }));
+ }, []);
+
+ const addAxiom = useCallback(() => {
+ setDraftDiff((prev) => ({
+ ...prev,
+ added_axioms: [...prev.added_axioms, { type: "subClassOf", value: "" }],
+ }));
+ }, []);
+
+ const autoLayout = useCallback(() => {
+ const layoutNodes = nodes.map((node, index) => ({
+ ...node,
+ position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
+ }));
+ setNodes(layoutNodes);
+ }, [nodes, setNodes]);
+
+ const saveDraft = useCallback(async () => {
+ if (!ontologyUri) {
+ alert("Please select an ontology first");
+ return;
+ }
+ setIsSaving(true);
+ try {
+ const response = await fetch("/api/ontology/draft", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ontology_uri: ontologyUri,
+ diff: draftDiff,
+ author: "user",
+ summary: "Visual editor changes",
+ }),
+ });
+ if (response.ok) {
+ const data = await response.json();
+ alert(`Draft saved: ${data.draft_id}`);
+ }
+ } catch (error) {
+ console.error("Failed to save draft:", error);
+ alert("Failed to save draft");
+ } finally {
+ setIsSaving(false);
+ }
+ }, [ontologyUri, draftDiff]);
+
+ const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
+ event.preventDefault();
+ setShowContext({ x: event.clientX, y: event.clientY, type: "node", element: node });
+ }, []);
+
+ const handleEdgeContextMenu = useCallback((event: React.MouseEvent, edge: Edge) => {
+ event.preventDefault();
+ setShowContext({ x: event.clientX, y: event.clientY, type: "edge", element: edge });
+ }, []);
+
+ const deleteSelected = useCallback(() => {
+ if (selectedElement) {
+ if ("source" in selectedElement) {
+ setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
+ setDraftDiff((prev) => ({
+ ...prev,
+ removed_properties: [...prev.removed_properties, selectedElement.id],
+ }));
+ } else {
+ setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
+ setDraftDiff((prev) => ({
+ ...prev,
+ removed_classes: [...prev.removed_classes, selectedElement.id],
+ }));
+ }
+ setSelectedElement(null);
+ }
+ setShowContext(null);
+ }, [selectedElement, setNodes, setEdges]);
+
+ const renameSelected = useCallback(() => {
+ if (selectedElement && !("source" in selectedElement)) {
+ const newLabel = prompt("Enter new name:", selectedElement.data.label);
+ if (newLabel) {
+ setNodes((nds) =>
+ nds.map((n) => (n.id === selectedElement.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
+ );
+ setDraftDiff((prev) => ({
+ ...prev,
+ modified_classes: { ...prev.modified_classes, [selectedElement.id]: { label: newLabel } },
+ }));
+ }
+ }
+ setShowContext(null);
+ }, [selectedElement, setNodes]);
+
+ useEffect(() => {
+ const handleClick = () => setShowContext(null);
+ window.addEventListener("click", handleClick);
+ return () => window.removeEventListener("click", handleClick);
+ }, []);
+
+ const toolbarStyle: React.CSSProperties = {
+ display: "flex",
+ gap: "8px",
+ padding: "12px 16px",
+ background: "rgba(3, 9, 18, 0.92)",
+ borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
+ flexWrap: "wrap",
+ };
+
+ const toolbarButtonStyle: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ padding: "8px 12px",
+ borderRadius: "8px",
+ border: "1px solid rgba(127, 208, 255, 0.18)",
+ background: "rgba(74, 163, 255, 0.08)",
+ color: "#ebf3ff",
+ fontSize: "12px",
+ fontWeight: "600",
+ cursor: "pointer",
+ transition: "160ms ease",
+ };
+
+ const contextMenuStyle: React.CSSProperties = {
+ position: "fixed",
+ background: "rgba(9, 19, 34, 0.95)",
+ border: "1px solid rgba(127, 208, 255, 0.3)",
+ borderRadius: "8px",
+ padding: "8px 0",
+ minWidth: "180px",
+ boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
+ zIndex: 1000,
+ };
+
+ const contextItemStyle: React.CSSProperties = {
+ padding: "8px 16px",
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ color: "#ebf3ff",
+ fontSize: "13px",
+ cursor: "pointer",
+ transition: "160ms ease",
+ };
+
+ const detailPanelStyle: React.CSSProperties = {
+ position: "absolute",
+ right: 0,
+ top: 0,
+ bottom: 0,
+ width: "320px",
+ background: "rgba(9, 19, 34, 0.95)",
+ borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
+ padding: "20px",
+ overflow: "auto",
+ backdropFilter: "blur(18px)",
+ };
+
+ return (
+
+
+
+
+ Add Class
+
+
+
+ Add Property
+
+
+
+ Add Individual
+
+
+
+ Add Restriction
+
+
+
+ Add Axiom
+
+
+
+ Auto Layout
+
+
+
+
+ {isSaving ? "Saving..." : "Propose"}
+
+
+
+
+
setSelectedElement(node)}
+ onEdgeClick={(_, edge) => setSelectedElement(edge)}
+ onNodeContextMenu={handleNodeContextMenu}
+ onEdgeContextMenu={handleEdgeContextMenu}
+ nodeTypes={nodeTypes}
+ fitView
+ style={{ background: "#07111f" }}
+ >
+
+
+
+
+
+ {showContext && (
+
+ )}
+
+ {selectedElement && (
+
+
+ {"source" in selectedElement ? "Property Details" : "Class Details"}
+
+
+
+ ID
+
+
+ {selectedElement.id}
+
+
+ {!("source" in selectedElement) && (
+ <>
+
+
+ Label
+
+ {
+ setNodes((nds) =>
+ nds.map((n) =>
+ n.id === selectedElement.id
+ ? { ...n, data: { ...n.data, label: e.target.value } }
+ : n
+ )
+ );
+ }}
+ style={{
+ width: "100%",
+ padding: "8px",
+ borderRadius: "6px",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ background: "rgba(3, 9, 18, 0.8)",
+ color: "#ebf3ff",
+ fontSize: "13px",
+ }}
+ />
+
+
+
+ Type
+
+
+ {selectedElement.data.type || "owl:Class"}
+
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx b/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx
new file mode 100644
index 00000000..226778a3
--- /dev/null
+++ b/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx
@@ -0,0 +1,413 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ GitMerge,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Send,
+ MessageSquare,
+ User,
+ Clock,
+ Plus,
+ Minus,
+ Edit,
+} from "lucide-react";
+
+interface Proposal {
+ proposal_id: string;
+ draft_id: string;
+ ontology_uri: string;
+ summary: string;
+ author: string;
+ reviewer: string | null;
+ state: "draft" | "proposed" | "approved" | "published" | "rejected";
+ impact_analysis: Record;
+ shacl_validation: Record;
+ created_at: string;
+ updated_at: string;
+ comments: Record[];
+}
+
+interface DiffChange {
+ type: "added" | "removed" | "modified";
+ element: string;
+ details?: Record;
+}
+
+export function ProposalReview({ proposalId }: { proposalId: string }) {
+ const [proposal, setProposal] = useState(null);
+ const [diff, setDiff] = useState([]);
+ const [selectedElement, setSelectedElement] = useState(null);
+ const [commentText, setCommentText] = useState("");
+
+ const loadProposal = useCallback(async () => {
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposalId}`);
+ if (response.ok) {
+ const data = await response.json();
+ setProposal(data);
+ generateDiff(data);
+ }
+ } catch (error) {
+ console.error("Failed to load proposal:", error);
+ }
+ }, [proposalId]);
+
+ const generateDiff = useCallback((prop: Proposal) => {
+ const changes: DiffChange[] = [];
+
+ // Generate diff from impact analysis
+ if (prop.impact_analysis) {
+ if (prop.impact_analysis.class_adds > 0) {
+ changes.push({ type: "added", element: `Classes (${prop.impact_analysis.class_adds})` });
+ }
+ if (prop.impact_analysis.class_removals > 0) {
+ changes.push({ type: "removed", element: `Classes (${prop.impact_analysis.class_removals})` });
+ }
+ if (prop.impact_analysis.property_changes > 0) {
+ changes.push({ type: "modified", element: `Properties (${prop.impact_analysis.property_changes})` });
+ }
+ if (prop.impact_analysis.restriction_changes > 0) {
+ changes.push({ type: "modified", element: `Restrictions (${prop.impact_analysis.restriction_changes})` });
+ }
+ }
+
+ setDiff(changes);
+ }, []);
+
+ useEffect(() => {
+ loadProposal();
+ }, [loadProposal]);
+
+ const addComment = useCallback(async () => {
+ if (!selectedElement || !commentText || !proposal) return;
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/comment`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ element_uri: selectedElement,
+ text: commentText,
+ author: "user",
+ }),
+ });
+ if (response.ok) {
+ setCommentText("");
+ loadProposal();
+ }
+ } catch (error) {
+ console.error("Failed to add comment:", error);
+ alert("Failed to add comment");
+ }
+ }, [selectedElement, commentText, proposal, loadProposal]);
+
+ const approveProposal = useCallback(async () => {
+ if (!proposal) return;
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/approve`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal approved");
+ loadProposal();
+ }
+ } catch (error) {
+ console.error("Failed to approve proposal:", error);
+ alert("Failed to approve proposal");
+ }
+ }, [proposal, loadProposal]);
+
+ const rejectProposal = useCallback(async () => {
+ if (!proposal) return;
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/reject`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal rejected");
+ loadProposal();
+ }
+ } catch (error) {
+ console.error("Failed to reject proposal:", error);
+ alert("Failed to reject proposal");
+ }
+ }, [proposal, loadProposal]);
+
+ const publishProposal = useCallback(async () => {
+ if (!proposal) return;
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/publish`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal published");
+ loadProposal();
+ }
+ } catch (error) {
+ console.error("Failed to publish proposal:", error);
+ alert("Failed to publish proposal");
+ }
+ }, [proposal, loadProposal]);
+
+ const getChangeIcon = (type: string) => {
+ switch (type) {
+ case "added":
+ return ;
+ case "removed":
+ return ;
+ case "modified":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ const getStateIcon = (state: string) => {
+ switch (state) {
+ case "published":
+ return ;
+ case "approved":
+ return ;
+ case "rejected":
+ return ;
+ case "proposed":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const containerStyle: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ height: "100%",
+ background: "#07111f",
+ padding: "20px",
+ overflow: "auto",
+ };
+
+ const headerStyle: React.CSSProperties = {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "20px",
+ paddingBottom: "16px",
+ borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
+ };
+
+ const titleStyle: React.CSSProperties = {
+ margin: 0,
+ color: "#ebf3ff",
+ fontSize: "20px",
+ fontWeight: "700",
+ };
+
+ const contentStyle: React.CSSProperties = {
+ display: "flex",
+ gap: "20px",
+ flex: 1,
+ minHeight: 0,
+ };
+
+ const diffPanelStyle: React.CSSProperties = {
+ flex: 1,
+ background: "rgba(9, 19, 34, 0.8)",
+ borderRadius: "8px",
+ border: "1px solid rgba(127, 208, 255, 0.12)",
+ padding: "16px",
+ overflow: "auto",
+ };
+
+ const commentsPanelStyle: React.CSSProperties = {
+ width: "320px",
+ background: "rgba(9, 19, 34, 0.8)",
+ borderRadius: "8px",
+ border: "1px solid rgba(127, 208, 255, 0.12)",
+ padding: "16px",
+ display: "flex",
+ flexDirection: "column",
+ };
+
+ const diffItemStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ padding: "10px 12px",
+ borderRadius: "6px",
+ background: "rgba(3, 9, 18, 0.6)",
+ marginBottom: "8px",
+ cursor: "pointer",
+ transition: "160ms ease",
+ };
+
+ const buttonStyle: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ padding: "8px 14px",
+ borderRadius: "6px",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ background: "rgba(74, 163, 255, 0.1)",
+ color: "#ebf3ff",
+ fontSize: "12px",
+ fontWeight: "600",
+ cursor: "pointer",
+ transition: "160ms ease",
+ };
+
+ const textareaStyle: React.CSSProperties = {
+ width: "100%",
+ padding: "10px 12px",
+ borderRadius: "6px",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ background: "rgba(3, 9, 18, 0.8)",
+ color: "#ebf3ff",
+ fontSize: "13px",
+ resize: "vertical",
+ minHeight: "80px",
+ };
+
+ if (!proposal) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {getStateIcon(proposal.state)}
+
+
{proposal.summary}
+
+ {proposal.author} • {new Date(proposal.created_at).toLocaleString()}
+
+
+
+
+ {proposal.state === "proposed" && (
+ <>
+
+
+ Approve
+
+
+
+ Reject
+
+ >
+ )}
+ {proposal.state === "approved" && (
+
+
+ Publish
+
+ )}
+
+
+
+
+
+
+
+ Diff Viewer
+
+ {diff.length === 0 ? (
+
No changes detected
+ ) : (
+ diff.map((change, index) => (
+
setSelectedElement(change.element)}
+ >
+ {getChangeIcon(change.type)}
+
+
+ {change.element}
+
+
+ {change.type}
+
+
+
+ ))
+ )}
+
+
+
+ Impact Analysis
+
+
+ {JSON.stringify(proposal.impact_analysis, null, 2)}
+
+
+
+
+
+ SHACL Validation
+
+
+ {JSON.stringify(proposal.shacl_validation, null, 2)}
+
+
+
+
+
+
+
+ Comments ({proposal.comments.length})
+
+
+ {proposal.comments.length === 0 ? (
+
No comments yet
+ ) : (
+ proposal.comments.map((comment) => (
+
+
+
+
+ {comment.author}
+
+
+
{comment.text}
+
+ {new Date(comment.created_at).toLocaleString()}
+
+
+ ))
+ )}
+
+ {selectedElement && (
+
+
+ )}
+
+
+
+ );
+}
diff --git a/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx b/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx
new file mode 100644
index 00000000..a11ec8f5
--- /dev/null
+++ b/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx
@@ -0,0 +1,510 @@
+import { useCallback, useEffect, useState } from "react";
+import {
+ Layers,
+ GitMerge,
+ Clock,
+ User,
+ FileText,
+ CheckCircle,
+ XCircle,
+ AlertCircle,
+ Send,
+ X,
+ ArrowRight,
+ Scale,
+ MessageSquare,
+} from "lucide-react";
+
+interface VersionEntry {
+ version_id: string;
+ ontology_uri: string;
+ state: "draft" | "published";
+ author: string;
+ date: string;
+ diff_summary: Record;
+}
+
+interface Proposal {
+ proposal_id: string;
+ draft_id: string;
+ ontology_uri: string;
+ summary: string;
+ author: string;
+ reviewer: string | null;
+ state: "draft" | "proposed" | "approved" | "published" | "rejected";
+ impact_analysis: Record;
+ shacl_validation: Record;
+ created_at: string;
+ updated_at: string;
+ comments: Record[];
+}
+
+export function VersionsTab() {
+ const [ontologyUri, setOntologyUri] = useState("");
+ const [versions, setVersions] = useState([]);
+ const [proposals, setProposals] = useState([]);
+ const [selectedProposal, setSelectedProposal] = useState(null);
+ const [showProposalModal, setShowProposalModal] = useState(false);
+ const [showCompareModal, setShowCompareModal] = useState(false);
+ const [compareVersions, setCompareVersions] = useState<{ v1: string; v2: string } | null>(null);
+ const [compareResult, setCompareResult] = useState | null>(null);
+ const [proposalSummary, setProposalSummary] = useState("");
+ const [reviewer, setReviewer] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadVersions = useCallback(async () => {
+ if (!ontologyUri) return;
+ try {
+ const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}`);
+ if (response.ok) {
+ const data = await response.json();
+ setVersions(data);
+ }
+ } catch (error) {
+ console.error("Failed to load versions:", error);
+ }
+ }, [ontologyUri]);
+
+ const loadProposals = useCallback(async () => {
+ try {
+ const response = await fetch("/api/ontology/proposals");
+ if (response.ok) {
+ const data = await response.json();
+ setProposals(data);
+ }
+ } catch (error) {
+ console.error("Failed to load proposals:", error);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadVersions();
+ loadProposals();
+ }, [loadVersions, loadProposals]);
+
+ const submitProposal = useCallback(async () => {
+ if (!selectedProposal || !proposalSummary) {
+ alert("Please provide a summary");
+ return;
+ }
+ setIsLoading(true);
+ try {
+ const response = await fetch("/api/ontology/propose", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ draft_id: selectedProposal.draft_id,
+ ontology_uri: ontologyUri,
+ summary: proposalSummary,
+ reviewer: reviewer || null,
+ }),
+ });
+ if (response.ok) {
+ const data = await response.json();
+ alert(`Proposal submitted: ${data.proposal_id}`);
+ setShowProposalModal(false);
+ loadProposals();
+ }
+ } catch (error) {
+ console.error("Failed to submit proposal:", error);
+ alert("Failed to submit proposal");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedProposal, proposalSummary, reviewer, ontologyUri, loadProposals]);
+
+ const approveProposal = useCallback(async (proposalId: string) => {
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposalId}/approve`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal approved");
+ loadProposals();
+ }
+ } catch (error) {
+ console.error("Failed to approve proposal:", error);
+ alert("Failed to approve proposal");
+ }
+ }, [loadProposals]);
+
+ const rejectProposal = useCallback(async (proposalId: string) => {
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposalId}/reject`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal rejected");
+ loadProposals();
+ }
+ } catch (error) {
+ console.error("Failed to reject proposal:", error);
+ alert("Failed to reject proposal");
+ }
+ }, [loadProposals]);
+
+ const publishProposal = useCallback(async (proposalId: string) => {
+ try {
+ const response = await fetch(`/api/ontology/proposals/${proposalId}/publish`, {
+ method: "POST",
+ });
+ if (response.ok) {
+ alert("Proposal published");
+ loadProposals();
+ loadVersions();
+ }
+ } catch (error) {
+ console.error("Failed to publish proposal:", error);
+ alert("Failed to publish proposal");
+ }
+ }, [loadProposals, loadVersions]);
+
+ const compareVersions = useCallback(async () => {
+ if (!compareVersions || !ontologyUri) return;
+ setIsLoading(true);
+ try {
+ const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}/compare`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ version1: compareVersions.v1,
+ version2: compareVersions.v2,
+ }),
+ });
+ if (response.ok) {
+ const data = await response.json();
+ setCompareResult(data);
+ }
+ } catch (error) {
+ console.error("Failed to compare versions:", error);
+ alert("Failed to compare versions");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [compareVersions, ontologyUri]);
+
+ const getStateIcon = (state: string) => {
+ switch (state) {
+ case "published":
+ return ;
+ case "approved":
+ return ;
+ case "rejected":
+ return ;
+ case "proposed":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const containerStyle: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ height: "100%",
+ background: "#07111f",
+ padding: "20px",
+ overflow: "auto",
+ };
+
+ const headerStyle: React.CSSProperties = {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "20px",
+ };
+
+ const titleStyle: React.CSSProperties = {
+ margin: 0,
+ color: "#ebf3ff",
+ fontSize: "20px",
+ fontWeight: "700",
+ };
+
+ const sectionStyle: React.CSSProperties = {
+ marginBottom: "24px",
+ };
+
+ const sectionTitleStyle: React.CSSProperties = {
+ margin: "0 0 12px",
+ color: "#ebf3ff",
+ fontSize: "14px",
+ fontWeight: "600",
+ display: "flex",
+ alignItems: "center",
+ gap: "8px",
+ };
+
+ const listStyle: React.CSSProperties = {
+ display: "flex",
+ flexDirection: "column",
+ gap: "8px",
+ };
+
+ const itemStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
+ padding: "12px 16px",
+ borderRadius: "8px",
+ background: "rgba(9, 19, 34, 0.8)",
+ border: "1px solid rgba(127, 208, 255, 0.12)",
+ transition: "160ms ease",
+ };
+
+ const modalOverlayStyle: React.CSSProperties = {
+ position: "fixed",
+ inset: 0,
+ background: "rgba(0, 0, 0, 0.7)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 1000,
+ };
+
+ const modalStyle: React.CSSProperties = {
+ background: "rgba(9, 19, 34, 0.95)",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ borderRadius: "12px",
+ padding: "24px",
+ minWidth: "480px",
+ maxWidth: "640px",
+ maxHeight: "80vh",
+ overflow: "auto",
+ backdropFilter: "blur(18px)",
+ };
+
+ const buttonStyle: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ padding: "8px 14px",
+ borderRadius: "6px",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ background: "rgba(74, 163, 255, 0.1)",
+ color: "#ebf3ff",
+ fontSize: "12px",
+ fontWeight: "600",
+ cursor: "pointer",
+ transition: "160ms ease",
+ };
+
+ const inputStyle: React.CSSProperties = {
+ width: "100%",
+ padding: "10px 12px",
+ borderRadius: "6px",
+ border: "1px solid rgba(127, 208, 255, 0.2)",
+ background: "rgba(3, 9, 18, 0.8)",
+ color: "#ebf3ff",
+ fontSize: "13px",
+ marginBottom: "12px",
+ };
+
+ return (
+
+
+
Versions & Change Proposals
+ setOntologyUri(e.target.value)}
+ style={{ ...inputStyle, width: "300px", marginBottom: 0 }}
+ />
+
+
+
+
+
+ Version History
+
+
+ {versions.length === 0 ? (
+
No versions found
+ ) : (
+ versions.map((version) => (
+
+ {getStateIcon(version.state)}
+
+
+ {version.version_id}
+
+
+ {version.author} • {new Date(version.date).toLocaleDateString()}
+
+
+
{
+ setCompareVersions({ v1: version.version_id, v2: versions[0]?.version_id || "" });
+ setShowCompareModal(true);
+ }}
+ >
+
+ Compare
+
+
+ ))
+ )}
+
+
+
+
+
+
+ Change Proposals
+
+
+ {proposals.length === 0 ? (
+
No proposals found
+ ) : (
+ proposals.map((proposal) => (
+
+ {getStateIcon(proposal.state)}
+
+
+ {proposal.summary}
+
+
+ {proposal.author} • {new Date(proposal.created_at).toLocaleDateString()}
+
+
+ {proposal.state === "proposed" && (
+
+ approveProposal(proposal.proposal_id)}>
+
+ Approve
+
+ rejectProposal(proposal.proposal_id)}>
+
+ Reject
+
+
+ )}
+ {proposal.state === "approved" && (
+
publishProposal(proposal.proposal_id)}>
+
+ Publish
+
+ )}
+
{
+ setSelectedProposal(proposal);
+ setShowProposalModal(true);
+ }}
+ >
+
+ Details
+
+
+ ))
+ )}
+
+
+
+ {showProposalModal && selectedProposal && (
+
setShowProposalModal(false)}>
+
e.stopPropagation()}>
+
+
Proposal Details
+ setShowProposalModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
+
+
+
+
+
+ Summary
+
+
{selectedProposal.summary}
+
+
+
+ State
+
+
+ {getStateIcon(selectedProposal.state)}
+ {selectedProposal.state}
+
+
+
+
+ Impact Analysis
+
+
+ {JSON.stringify(selectedProposal.impact_analysis, null, 2)}
+
+
+
+
+ SHACL Validation
+
+
+ {JSON.stringify(selectedProposal.shacl_validation, null, 2)}
+
+
+
+
+ Comments ({selectedProposal.comments.length})
+
+
+ {selectedProposal.comments.map((comment) => (
+
+
{comment.author}
+
{comment.text}
+
+ ))}
+
+
+
+
+ )}
+
+ {showCompareModal && compareVersions && (
+
setShowCompareModal(false)}>
+
e.stopPropagation()}>
+
+
Compare Versions
+ setShowCompareModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
+
+
+
+
+
+
+ {isLoading ? "Comparing..." : "Compare"}
+
+ {compareResult && (
+
+ {JSON.stringify(compareResult, null, 2)}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/explorer/src/workspaces/OntologyWorkspace/index.tsx b/explorer/src/workspaces/OntologyWorkspace/index.tsx
index 49b9f488..3e0fd251 100644
--- a/explorer/src/workspaces/OntologyWorkspace/index.tsx
+++ b/explorer/src/workspaces/OntologyWorkspace/index.tsx
@@ -8,6 +8,8 @@ import {
Sliders,
} from "lucide-react";
import { OntologyManager } from "./OntologyManager";
+import { OntologyEditor } from "./OntologyEditor";
+import { VersionsTab } from "./VersionsTab";
export type OntologyHubTab =
| "registry"
@@ -92,23 +94,9 @@ export function OntologyWorkspace() {
case "registry":
return ;
case "editor":
- return (
-
- );
+ return ;
case "versions":
- return (
-
- );
+ return ;
case "alignments":
return (
Dict[str, OntologyEntry]:
return request.app.state.ontology_registry
+def _get_drafts(request: Request) -> Dict[str, DraftResponse]:
+ if not hasattr(request.app.state, "ontology_drafts"):
+ request.app.state.ontology_drafts = {}
+ return request.app.state.ontology_drafts
+
+
+def _get_proposals(request: Request) -> Dict[str, ProposalResponse]:
+ if not hasattr(request.app.state, "ontology_proposals"):
+ request.app.state.ontology_proposals = {}
+ return request.app.state.ontology_proposals
+
+
+def _get_versions(request: Request) -> Dict[str, List[VersionEntry]]:
+ if not hasattr(request.app.state, "ontology_versions"):
+ request.app.state.ontology_versions = {}
+ return request.app.state.ontology_versions
+
+
def _uri_to_prefix(uri: str) -> str:
for base, prefix in _URI_PREFIX_MAP.items():
if uri.startswith(base):
@@ -250,6 +391,82 @@ def _node_label(node: Dict[str, Any]) -> str:
)
+def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+ """Convert OntologyIngestor ontology dict to graph nodes and edges."""
+ nodes = []
+ edges = []
+
+ # Add ontology node
+ ontology_uri = ontology_dict.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
+ nodes.append({
+ "id": ontology_uri,
+ "type": "owl:Ontology",
+ "content": ontology_dict.get("name", "Ontology"),
+ "properties": {
+ "rdfs:label": ontology_dict.get("name", "Ontology"),
+ "rdfs:comment": ontology_dict.get("description", ""),
+ "uri": ontology_uri,
+ },
+ })
+
+ # Add class nodes
+ for cls in ontology_dict.get("classes", []):
+ cls_uri = cls.get("uri", f"temp:class:{uuid.uuid4().hex[:12]}")
+ node = {
+ "id": cls_uri,
+ "type": "owl:Class",
+ "content": cls.get("name", cls.get("label", "")),
+ "properties": {
+ "rdfs:label": cls.get("label", cls.get("name", "")),
+ "rdfs:comment": cls.get("description", ""),
+ "uri": cls_uri,
+ },
+ }
+ nodes.append(node)
+
+ # Add subclass edges
+ for parent in cls.get("parents", []):
+ edges.append({
+ "source": cls_uri,
+ "target": parent,
+ "type": "rdfs:subClassOf",
+ "weight": 1.0,
+ })
+
+ # Add property nodes and edges
+ for prop in ontology_dict.get("properties", []):
+ prop_uri = prop.get("uri", f"temp:prop:{uuid.uuid4().hex[:12]}")
+ node = {
+ "id": prop_uri,
+ "type": f"owl:{prop.get('type', 'Object').title()}Property",
+ "content": prop.get("name", prop.get("label", "")),
+ "properties": {
+ "rdfs:label": prop.get("label", prop.get("name", "")),
+ "rdfs:comment": prop.get("description", ""),
+ "uri": prop_uri,
+ },
+ }
+ nodes.append(node)
+
+ # Add domain and range edges
+ if prop.get("domain"):
+ edges.append({
+ "source": prop_uri,
+ "target": prop["domain"],
+ "type": "rdfs:domain",
+ "weight": 1.0,
+ })
+ if prop.get("range"):
+ edges.append({
+ "source": prop_uri,
+ "target": prop["range"],
+ "type": "rdfs:range",
+ "weight": 1.0,
+ })
+
+ return nodes, edges
+
+
def _extract_namespace(uri: str) -> Optional[str]:
if "#" in uri:
return uri.rsplit("#", 1)[0] + "#"
@@ -572,6 +789,73 @@ async def load_ontology(
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
try:
+ # Try to use OntologyIngestor for proper parsing and conversion
+ from ...ingest.ontology_ingestor import OntologyIngestor
+ from tempfile import NamedTemporaryFile
+
+ ingestor = OntologyIngestor()
+
+ # Write content to temporary file for ingestion
+ with NamedTemporaryFile(mode='w', suffix=f'.{fmt}', delete=False, encoding='utf-8') as temp_file:
+ temp_file.write(content_str)
+ temp_path = temp_file.name
+
+ try:
+ # Use OntologyIngestor to parse and convert
+ ontology_data = await asyncio.to_thread(
+ ingestor.ingest_ontology,
+ temp_path,
+ format=fmt
+ )
+
+ # Convert to graph nodes/edges using ontology data
+ nodes, edges = await asyncio.to_thread(
+ _convert_ontology_to_graph,
+ ontology_data.data
+ )
+
+ # Add nodes and edges to session
+ nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
+ edges_added = await asyncio.to_thread(session.add_edges, edges)
+
+ # Register in registry
+ registry = _get_registry(request)
+ ontology_uri = ontology_data.data.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
+ registry[ontology_uri] = OntologyEntry(
+ uri=ontology_uri,
+ name=ontology_data.data.get("name", "Imported Ontology"),
+ description=ontology_data.data.get("description"),
+ format=fmt,
+ status="loaded",
+ version=ontology_data.data.get("version", "1.0"),
+ class_count=len([n for n in nodes if n.get("type") in _CLASS_TYPES]),
+ concept_count=len([n for n in nodes if n.get("type") in _CONCEPT_TYPES]),
+ property_count=len([n for n in nodes if n.get("type") in _PROPERTY_TYPES]),
+ loaded_at=datetime.now(UTC).isoformat(),
+ enabled=True,
+ tags=body.tags,
+ source_url=body.url,
+ )
+
+ return LoadOntologyResponse(
+ uri=ontology_uri,
+ name=ontology_data.data.get("name", "Imported Ontology"),
+ nodes_added=nodes_added,
+ edges_added=edges_added,
+ format=fmt,
+ )
+
+ finally:
+ # Clean up temporary file
+ try:
+ os.unlink(temp_path)
+ except:
+ pass
+
+ except Exception as ingest_exc:
+ logger.warning(f"OntologyIngestor failed, falling back to basic parsing: {ingest_exc}")
+
+ # Fallback to basic parsing
nodes, edges, metadata = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), fmt
)
@@ -580,32 +864,34 @@ async def load_ontology(
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
- onto_uri = metadata.get("uri", f"urn:semantica:onto:{uuid.uuid4().hex[:8]}")
- onto_name = body.name or metadata.get("name", "Unnamed Ontology")
-
+ # Fallback path - use basic parsing
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
registry = _get_registry(request)
- registry[onto_uri] = OntologyEntry(
- uri=onto_uri,
- name=onto_name,
- description=body.description or metadata.get("description"),
+ ontology_uri = metadata.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
+ registry[ontology_uri] = OntologyEntry(
+ uri=ontology_uri,
+ name=metadata.get("name", "Imported Ontology"),
+ description=metadata.get("description"),
format=fmt,
- status="external",
- source_url=body.url,
- version=metadata.get("version"),
- class_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "class"),
- concept_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) in ("concept", "scheme")),
- property_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "property"),
+ status="loaded",
+ version=metadata.get("version", "1.0"),
+ class_count=sum(1 for n in nodes if n.get("type") in _CLASS_TYPES),
+ concept_count=sum(1 for n in nodes if n.get("type") in _CONCEPT_TYPES),
+ property_count=sum(1 for n in nodes if n.get("type") in _PROPERTY_TYPES),
loaded_at=datetime.now(UTC).isoformat(),
enabled=True,
tags=body.tags,
+ source_url=body.url,
)
return LoadOntologyResponse(
- uri=onto_uri, name=onto_name,
- nodes_added=nodes_added, edges_added=edges_added, format=fmt,
+ uri=ontology_uri,
+ name=metadata.get("name", "Imported Ontology"),
+ nodes_added=nodes_added,
+ edges_added=edges_added,
+ format=fmt,
)
@@ -615,8 +901,16 @@ async def create_ontology(
body: CreateOntologyRequest,
session: GraphSession = Depends(get_session),
):
+ """Create ontology from scratch, sample data, or text using OntologyEngine."""
ns = body.namespace.rstrip("/#")
onto_uri = f"{ns}#ontology"
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ if body.provider or body.model:
+ engine_config["provider"] = body.provider
+ engine_config["model"] = body.model
+
nodes: List[Dict[str, Any]] = [{
"id": onto_uri,
"type": "owl:Ontology",
@@ -632,32 +926,134 @@ async def create_ontology(
if body.mode == "data" and body.sample_data:
try:
from ...ontology import OntologyEngine
- engine = OntologyEngine()
+ engine = OntologyEngine(**engine_config)
result = await asyncio.to_thread(engine.from_data, body.sample_data)
- for cls in (result.get("classes", []) if isinstance(result, dict) else []):
- cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
- nodes.append({
- "id": cls_uri, "type": "owl:Class",
- "content": cls.get("name", ""),
- "properties": {"rdfs:label": cls.get("name", "")},
- })
- except Exception:
+
+ # Convert OntologyEngine result to graph nodes/edges
+ if isinstance(result, dict):
+ for cls in result.get("classes", []):
+ cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
+ nodes.append({
+ "id": cls_uri,
+ "type": "owl:Class",
+ "content": cls.get("name", ""),
+ "properties": {
+ "rdfs:label": cls.get("name", ""),
+ "rdfs:comment": cls.get("description", ""),
+ },
+ })
+
+ # Add property edges
+ for prop in result.get("properties", []):
+ prop_uri = f"{ns}/{prop.get('name', uuid.uuid4().hex[:6])}"
+ domain_uri = f"{ns}/{prop.get('domain', '')}"
+ range_uri = f"{ns}/{prop.get('range', '')}"
+
+ nodes.append({
+ "id": prop_uri,
+ "type": "owl:ObjectProperty",
+ "content": prop.get("name", ""),
+ "properties": {"rdfs:label": prop.get("name", "")},
+ })
+
+ if domain_uri:
+ edges.append({
+ "source": prop_uri,
+ "target": domain_uri,
+ "type": "rdfs:domain",
+ "weight": 1.0,
+ })
+ if range_uri:
+ edges.append({
+ "source": prop_uri,
+ "target": range_uri,
+ "type": "rdfs:range",
+ "weight": 1.0,
+ })
+
+ # Add subclass edges
+ for cls in result.get("classes", []):
+ cls_uri = f"{ns}/{cls.get('name', '')}"
+ for parent in cls.get("superclasses", []):
+ parent_uri = f"{ns}/{parent}"
+ edges.append({
+ "source": cls_uri,
+ "target": parent_uri,
+ "type": "rdfs:subClassOf",
+ "weight": 1.0,
+ })
+
+ logger.info(f"Generated ontology from sample data with {len(nodes)} nodes, {len(edges)} edges")
+
+ except Exception as exc:
logger.exception("Failed to generate ontology from sample data; falling back to minimal ontology.")
+ logger.warning(f"OntologyEngine.from_data error: {exc}")
elif body.mode == "text" and body.schema_text:
try:
from ...ontology import OntologyEngine
- engine = OntologyEngine()
- result = await asyncio.to_thread(engine.from_text, body.schema_text)
- for cls in (result.get("classes", []) if isinstance(result, dict) else []):
- cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
- nodes.append({
- "id": cls_uri, "type": "owl:Class",
- "content": cls.get("name", ""),
- "properties": {"rdfs:label": cls.get("name", "")},
- })
- except Exception:
+ engine = OntologyEngine(**engine_config)
+ result = await asyncio.to_thread(engine.from_text, body.schema_text, provider=body.provider, model=body.model)
+
+ # Convert OntologyEngine result to graph nodes/edges
+ if isinstance(result, dict):
+ for cls in result.get("classes", []):
+ cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
+ nodes.append({
+ "id": cls_uri,
+ "type": "owl:Class",
+ "content": cls.get("name", ""),
+ "properties": {
+ "rdfs:label": cls.get("name", ""),
+ "rdfs:comment": cls.get("description", ""),
+ },
+ })
+
+ # Add property edges
+ for prop in result.get("properties", []):
+ prop_uri = f"{ns}/{prop.get('name', uuid.uuid4().hex[:6])}"
+ domain_uri = f"{ns}/{prop.get('domain', '')}"
+ range_uri = f"{ns}/{prop.get('range', '')}"
+
+ nodes.append({
+ "id": prop_uri,
+ "type": "owl:ObjectProperty",
+ "content": prop.get("name", ""),
+ "properties": {"rdfs:label": prop.get("name", "")},
+ })
+
+ if domain_uri:
+ edges.append({
+ "source": prop_uri,
+ "target": domain_uri,
+ "type": "rdfs:domain",
+ "weight": 1.0,
+ })
+ if range_uri:
+ edges.append({
+ "source": prop_uri,
+ "target": range_uri,
+ "type": "rdfs:range",
+ "weight": 1.0,
+ })
+
+ # Add subclass edges
+ for cls in result.get("classes", []):
+ cls_uri = f"{ns}/{cls.get('name', '')}"
+ for parent in cls.get("superclasses", []):
+ parent_uri = f"{ns}/{parent}"
+ edges.append({
+ "source": cls_uri,
+ "target": parent_uri,
+ "type": "rdfs:subClassOf",
+ "weight": 1.0,
+ })
+
+ logger.info(f"Generated ontology from text with {len(nodes)} nodes, {len(edges)} edges")
+
+ except Exception as exc:
logger.exception("Failed to generate ontology from schema text; falling back to minimal ontology.")
+ logger.warning(f"OntologyEngine.from_text error: {exc}")
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
@@ -763,29 +1159,130 @@ async def get_entity_detail(
@router.get("/skos/schemes", response_model=List[SKOSScheme])
async def list_skos_schemes(session: GraphSession = Depends(get_session)):
- nodes, _ = await asyncio.to_thread(
- session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
- )
- # Count concepts per scheme from edges
- all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
- concept_counts: Dict[str, int] = {}
- for edge in all_edges:
- if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
- concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
- elif edge.get("type") == "skos:hasTopConcept":
- concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
+ """List SKOS concept schemes using OntologyEngine.list_vocabularies()."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Use OntologyEngine.list_vocabularies
+ vocabularies = await asyncio.to_thread(engine.list_vocabularies)
+
+ # Count concepts per scheme
+ all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
+ concept_counts: Dict[str, int] = {}
+ for node in all_nodes:
+ scheme_uri = node.get("properties", {}).get("scheme_uri")
+ if scheme_uri and node.get("type") in _CONCEPT_TYPES:
+ concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
+
+ return [
+ SKOSScheme(
+ uri=vocab["uri"],
+ title=vocab["label"] or vocab["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1],
+ description=None,
+ concept_count=concept_counts.get(vocab["uri"], 0),
+ )
+ for vocab in vocabularies
+ ]
+ except Exception as exc:
+ logger.warning(f"OntologyEngine.list_vocabularies failed, falling back to session: {exc}")
+ # Fallback to session-based implementation
+ nodes, _ = await asyncio.to_thread(
+ session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
+ )
+ all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
+ concept_counts: Dict[str, int] = {}
+ for edge in all_edges:
+ if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
+ concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
+ elif edge.get("type") == "skos:hasTopConcept":
+ concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
- result = []
- for node in nodes:
- props = node.get("properties", {})
- nid = node.get("id", "")
- result.append(SKOSScheme(
- uri=nid,
- title=_node_label(node),
- description=props.get("description") or props.get("skos:definition"),
- concept_count=concept_counts.get(nid, 0),
- ))
- return result
+ result = []
+ for node in nodes:
+ props = node.get("properties", {})
+ nid = node.get("id", "")
+ result.append(SKOSScheme(
+ uri=nid,
+ title=_node_label(node),
+ description=props.get("description") or props.get("skos:definition"),
+ concept_count=concept_counts.get(nid, 0),
+ ))
+ return result
+
+
+@router.post("/skos/search", response_model=List[OntologySearchResult])
+async def search_skos_concepts(
+ body: SKOSConceptSearchRequest,
+ session: GraphSession = Depends(get_session),
+):
+ """Search SKOS concepts using OntologyEngine.search_concepts()."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Use OntologyEngine.search_concepts
+ concepts = await asyncio.to_thread(
+ engine.search_concepts,
+ body.query,
+ scheme_uri=body.scheme_uri
+ )
+
+ return [
+ OntologySearchResult(
+ uri=concept["uri"],
+ label=concept["label"],
+ type="skos:Concept",
+ entity_type="concept",
+ definition=None,
+ source_ontology=body.scheme_uri,
+ namespace_prefix=_extract_namespace(concept["uri"]),
+ )
+ for concept in concepts
+ ]
+ except Exception as exc:
+ logger.warning(f"OntologyEngine.search_concepts failed, using fallback: {exc}")
+ # Fallback to session-based search
+ raw_hits = await asyncio.to_thread(session.search, body.query, 300)
+ results: List[OntologySearchResult] = []
+
+ for hit in raw_hits:
+ node = hit.get("node", hit)
+ ntype = node.get("type", "")
+ if ntype not in _CONCEPT_TYPES:
+ continue
+ if body.scheme_uri:
+ scheme = node.get("properties", {}).get("scheme_uri")
+ if scheme != body.scheme_uri:
+ continue
+
+ label = _node_label(node)
+ props = node.get("properties", {})
+ definition = (
+ props.get("rdfs:comment")
+ or props.get("skos:definition")
+ or props.get("description")
+ )
+
+ results.append(OntologySearchResult(
+ uri=node.get("id", ""),
+ label=label,
+ type=ntype,
+ entity_type="concept",
+ definition=definition,
+ source_ontology=props.get("scheme_uri"),
+ namespace_prefix=_extract_namespace(node.get("id", "")),
+ ))
+ if len(results) >= 50:
+ break
+
+ return results
@router.get("/skos/concept/{concept_uri:path}", response_model=SKOSConceptDetail)
@@ -793,50 +1290,134 @@ async def get_skos_concept(
concept_uri: str,
session: GraphSession = Depends(get_session),
):
- node = await asyncio.to_thread(session.get_node, concept_uri)
- if node is None:
- raise HTTPException(status_code=404, detail="Concept not found.")
+ """Get SKOS concept detail using OntologyEngine.list_concepts() and search_concepts()."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Try to get scheme_uri from node first
+ node = await asyncio.to_thread(session.get_node, concept_uri)
+ if node is None:
+ raise HTTPException(status_code=404, detail="Concept not found.")
+
+ scheme_uri = node.get("properties", {}).get("scheme_uri")
+
+ # Use OntologyEngine.list_concepts if scheme_uri is known
+ if scheme_uri:
+ concepts = await asyncio.to_thread(engine.list_concepts, scheme_uri)
+ concept_data = next((c for c in concepts if c["uri"] == concept_uri), None)
+
+ if concept_data:
+ return SKOSConceptDetail(
+ uri=concept_data["uri"],
+ pref_label=concept_data["pref_label"],
+ alt_labels=concept_data.get("alt_labels", []),
+ hidden_labels=[],
+ definition=None,
+ scope_note=None,
+ editorial_note=None,
+ broader=[],
+ narrower=[],
+ related=[],
+ exact_match=[],
+ close_match=[],
+ broad_match=[],
+ narrow_match=[],
+ scheme_uri=scheme_uri,
+ )
+
+ # Fallback to session-based implementation
+ props = node.get("properties", {})
+ out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
+ in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
- props = node.get("properties", {})
- out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
- in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
+ def collect_out(rel: str) -> List[str]:
+ return [e["target"] for e in out_edges if e.get("type") == rel]
- def collect_out(rel: str) -> List[str]:
- return [e["target"] for e in out_edges if e.get("type") == rel]
+ def collect_in(rel: str) -> List[str]:
+ return [e["source"] for e in in_edges if e.get("type") == rel]
- def collect_in(rel: str) -> List[str]:
- return [e["source"] for e in in_edges if e.get("type") == rel]
+ pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
+ alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
+ if isinstance(alt_labels, str):
+ alt_labels = [alt_labels]
+ hidden_labels = props.get("skos:hiddenLabel") or []
+ if isinstance(hidden_labels, str):
+ hidden_labels = [hidden_labels]
- pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
- alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
- if isinstance(alt_labels, str):
- alt_labels = [alt_labels]
- hidden_labels = props.get("skos:hiddenLabel") or []
- if isinstance(hidden_labels, str):
- hidden_labels = [hidden_labels]
+ if not scheme_uri:
+ candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
+ scheme_uri = candidates[0] if candidates else None
- scheme_uri = props.get("scheme_uri")
- if not scheme_uri:
- candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
- scheme_uri = candidates[0] if candidates else None
+ return SKOSConceptDetail(
+ uri=concept_uri,
+ pref_label=pref_label,
+ alt_labels=list(alt_labels),
+ hidden_labels=list(hidden_labels),
+ definition=props.get("definition") or props.get("skos:definition"),
+ scope_note=props.get("skos:scopeNote"),
+ editorial_note=props.get("skos:editorialNote"),
+ broader=collect_out("skos:broader") + collect_in("skos:narrower"),
+ narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
+ related=collect_out("skos:related"),
+ exact_match=collect_out("skos:exactMatch"),
+ close_match=collect_out("skos:closeMatch"),
+ broad_match=collect_out("skos:broadMatch"),
+ narrow_match=collect_out("skos:narrowMatch"),
+ scheme_uri=scheme_uri,
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.warning(f"OntologyEngine SKOS methods failed, using fallback: {exc}")
+ # Fallback to session-based implementation
+ node = await asyncio.to_thread(session.get_node, concept_uri)
+ if node is None:
+ raise HTTPException(status_code=404, detail="Concept not found.")
- return SKOSConceptDetail(
- uri=concept_uri,
- pref_label=pref_label,
- alt_labels=list(alt_labels),
- hidden_labels=list(hidden_labels),
- definition=props.get("definition") or props.get("skos:definition"),
- scope_note=props.get("skos:scopeNote"),
- editorial_note=props.get("skos:editorialNote"),
- broader=collect_out("skos:broader") + collect_in("skos:narrower"),
- narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
- related=collect_out("skos:related"),
- exact_match=collect_out("skos:exactMatch"),
- close_match=collect_out("skos:closeMatch"),
- broad_match=collect_out("skos:broadMatch"),
- narrow_match=collect_out("skos:narrowMatch"),
- scheme_uri=scheme_uri,
- )
+ props = node.get("properties", {})
+ out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
+ in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
+
+ def collect_out(rel: str) -> List[str]:
+ return [e["target"] for e in out_edges if e.get("type") == rel]
+
+ def collect_in(rel: str) -> List[str]:
+ return [e["source"] for e in in_edges if e.get("type") == rel]
+
+ pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
+ alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
+ if isinstance(alt_labels, str):
+ alt_labels = [alt_labels]
+ hidden_labels = props.get("skos:hiddenLabel") or []
+ if isinstance(hidden_labels, str):
+ hidden_labels = [hidden_labels]
+
+ scheme_uri = props.get("scheme_uri")
+ if not scheme_uri:
+ candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
+ scheme_uri = candidates[0] if candidates else None
+
+ return SKOSConceptDetail(
+ uri=concept_uri,
+ pref_label=pref_label,
+ alt_labels=list(alt_labels),
+ hidden_labels=list(hidden_labels),
+ definition=props.get("definition") or props.get("skos:definition"),
+ scope_note=props.get("skos:scopeNote"),
+ editorial_note=props.get("skos:editorialNote"),
+ broader=collect_out("skos:broader") + collect_in("skos:narrower"),
+ narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
+ related=collect_out("skos:related"),
+ exact_match=collect_out("skos:exactMatch"),
+ close_match=collect_out("skos:closeMatch"),
+ broad_match=collect_out("skos:broadMatch"),
+ narrow_match=collect_out("skos:narrowMatch"),
+ scheme_uri=scheme_uri,
+ )
# ---------------------------------------------------------------------------
@@ -892,3 +1473,575 @@ async def refresh_ontology(
entry.loaded_at = datetime.now(UTC).isoformat()
return RefreshResponse(uri=ontology_uri, nodes_added=nodes_added, edges_added=edges_added)
+
+
+# ---------------------------------------------------------------------------
+# Draft endpoints
+# ---------------------------------------------------------------------------
+
+@router.patch("/draft", response_model=DraftResponse)
+async def save_draft(
+ request: Request,
+ body: DraftRequest,
+):
+ """Stage editor diffs as a draft with ChangeLogEntry metadata."""
+ drafts = _get_drafts(request)
+ draft_id = f"draft_{uuid.uuid4().hex[:12]}"
+ now = datetime.now(UTC).isoformat()
+
+ # Create ChangeLogEntry for audit trail
+ try:
+ from ...change_management.change_log import ChangeLogEntry
+ change_log = ChangeLogEntry.create_now(
+ author=body.author,
+ description=body.summary or f"Draft changes for {body.ontology_uri}",
+ change_id=draft_id
+ )
+ except Exception as exc:
+ logger.warning(f"Failed to create ChangeLogEntry: {exc}")
+ change_log = None
+
+ draft = DraftResponse(
+ draft_id=draft_id,
+ ontology_uri=body.ontology_uri,
+ diff=body.diff,
+ author=body.author,
+ summary=body.summary,
+ created_at=now,
+ updated_at=now,
+ )
+ drafts[draft_id] = draft
+ return draft
+
+
+@router.get("/drafts/{ontology_uri:path}", response_model=List[DraftResponse])
+async def list_drafts(
+ ontology_uri: str,
+ request: Request,
+):
+ """Get staged draft diffs for an ontology."""
+ drafts = _get_drafts(request)
+ return [d for d in drafts.values() if d.ontology_uri == ontology_uri]
+
+
+@router.get("/draft/{draft_id}", response_model=DraftResponse)
+async def get_draft(
+ draft_id: str,
+ request: Request,
+):
+ """Get a specific draft by ID."""
+ drafts = _get_drafts(request)
+ if draft_id not in drafts:
+ raise HTTPException(status_code=404, detail="Draft not found.")
+ return drafts[draft_id]
+
+
+# ---------------------------------------------------------------------------
+# Proposal endpoints
+# ---------------------------------------------------------------------------
+
+@router.post("/propose", response_model=ProposalResponse)
+async def submit_proposal(
+ request: Request,
+ body: ProposalRequest,
+ session: GraphSession = Depends(get_session),
+):
+ """Submit a change proposal with impact analysis and SHACL pre-validation."""
+ drafts = _get_drafts(request)
+ proposals = _get_proposals(request)
+
+ if body.draft_id not in drafts:
+ raise HTTPException(status_code=404, detail="Draft not found.")
+
+ draft = drafts[body.draft_id]
+ proposal_id = f"prop_{uuid.uuid4().hex[:12]}"
+ now = datetime.now(UTC).isoformat()
+
+ # Compute impact analysis using VersionManager.diff_ontologies and OntologyEngine
+ impact_analysis = {}
+ shacl_validation = {}
+
+ try:
+ from ...ontology import OntologyEngine
+ from ...change_management.ontology_version_manager import VersionManager
+
+ # Build ontology dicts from draft diff for comparison
+ base_ontology = {"uri": body.ontology_uri, "classes": [], "properties": []}
+ target_ontology = {
+ "uri": body.ontology_uri,
+ "classes": [{"uri": uri} for uri in draft.diff.added_classes],
+ "properties": [{"uri": uri} for uri in draft.diff.added_properties],
+ }
+
+ # Use VersionManager.diff_ontologies for structured diff
+ version_manager = VersionManager(
+ store=session.graph.store if hasattr(session.graph, "store") else None
+ )
+ diff_result = await asyncio.to_thread(
+ version_manager.diff_ontologies,
+ base_ontology,
+ target_ontology
+ )
+
+ # Use OntologyEngine for validation and SHACL
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Run validation if available
+ validation_results = {}
+ try:
+ val_res = await asyncio.to_thread(engine.validate, target_ontology)
+ validation_results = {
+ "valid": getattr(val_res, "valid", getattr(val_res, "is_valid", False)),
+ "consistent": getattr(val_res, "consistent", True),
+ "satisfiable": getattr(val_res, "satisfiable", True),
+ "errors": getattr(val_res, "errors", []),
+ "warnings": getattr(val_res, "warnings", [])
+ }
+ except Exception as val_exc:
+ logger.warning(f"Validation failed: {val_exc}")
+ validation_results = {"error": str(val_exc)}
+
+ # SHACL pre-validation using OntologyEngine.validate_graph
+ if engine.store:
+ try:
+ # Generate SHACL from target ontology
+ shacl_shapes = await asyncio.to_thread(
+ engine.to_shacl,
+ target_ontology,
+ format="turtle"
+ )
+
+ # Validate current graph data against new SHACL shapes
+ all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
+ graph_data = {"nodes": all_nodes}
+
+ validation_report = await asyncio.to_thread(
+ engine.validate_graph,
+ graph_data,
+ ontology=target_ontology,
+ explain=True
+ )
+
+ shacl_validation = {
+ "status": "validated",
+ "conforms": getattr(validation_report, "conforms", True),
+ "violations": [
+ {
+ "message": v.message,
+ "severity": v.severity,
+ "focus_node": v.focus_node,
+ }
+ for v in getattr(validation_report, "violations", [])
+ ]
+ }
+ except Exception as shacl_exc:
+ logger.warning(f"SHACL pre-validation failed: {shacl_exc}")
+ shacl_validation = {"status": "error", "error": str(shacl_exc)}
+ else:
+ shacl_validation = {"status": "skipped", "reason": "No store configured"}
+
+ impact_analysis = {
+ "diff": diff_result,
+ "validation_results": validation_results,
+ "class_adds": len(draft.diff.added_classes),
+ "class_removals": len(draft.diff.removed_classes),
+ "property_changes": len(draft.diff.added_properties) + len(draft.diff.removed_properties),
+ "restriction_changes": len(draft.diff.added_restrictions) + len(draft.diff.removed_restrictions),
+ }
+
+ except Exception as exc:
+ logger.warning(f"Impact analysis failed: {exc}")
+ impact_analysis = {"error": str(exc)}
+ shacl_validation = {"status": "error", "error": str(exc)}
+
+ proposal = ProposalResponse(
+ proposal_id=proposal_id,
+ draft_id=body.draft_id,
+ ontology_uri=body.ontology_uri,
+ summary=body.summary,
+ author=draft.author,
+ reviewer=body.reviewer,
+ state="proposed",
+ impact_analysis=impact_analysis,
+ shacl_validation=shacl_validation,
+ created_at=now,
+ updated_at=now,
+ comments=[],
+ )
+ proposals[proposal_id] = proposal
+ return proposal
+
+
+@router.get("/proposals", response_model=List[ProposalResponse])
+async def list_proposals(
+ request: Request,
+ ontology_uri: Optional[str] = Query(None),
+ state: Optional[str] = Query(None),
+):
+ """List proposals with optional filters."""
+ proposals = _get_proposals(request)
+ result = list(proposals.values())
+
+ if ontology_uri:
+ result = [p for p in result if p.ontology_uri == ontology_uri]
+ if state:
+ result = [p for p in result if p.state == state]
+
+ return result
+
+
+@router.get("/proposals/{proposal_id}", response_model=ProposalResponse)
+async def get_proposal(
+ proposal_id: str,
+ request: Request,
+):
+ """Get proposal detail."""
+ proposals = _get_proposals(request)
+ if proposal_id not in proposals:
+ raise HTTPException(status_code=404, detail="Proposal not found.")
+ return proposals[proposal_id]
+
+
+@router.post("/proposals/{proposal_id}/approve")
+async def approve_proposal(
+ proposal_id: str,
+ request: Request,
+):
+ """Approve a proposal."""
+ proposals = _get_proposals(request)
+ if proposal_id not in proposals:
+ raise HTTPException(status_code=404, detail="Proposal not found.")
+ proposal = proposals[proposal_id]
+ proposal.state = "approved"
+ proposal.updated_at = datetime.now(UTC).isoformat()
+ return {"status": "approved", "proposal_id": proposal_id}
+
+
+@router.post("/proposals/{proposal_id}/reject")
+async def reject_proposal(
+ proposal_id: str,
+ request: Request,
+):
+ """Reject a proposal (can return to draft)."""
+ proposals = _get_proposals(request)
+ if proposal_id not in proposals:
+ raise HTTPException(status_code=404, detail="Proposal not found.")
+ proposal = proposals[proposal_id]
+ proposal.state = "rejected"
+ proposal.updated_at = datetime.now(UTC).isoformat()
+ return {"status": "rejected", "proposal_id": proposal_id}
+
+
+@router.post("/proposals/{proposal_id}/publish")
+async def publish_proposal(
+ proposal_id: str,
+ request: Request,
+ session: GraphSession = Depends(get_session),
+):
+ """Publish an approved proposal using VersionManager.create_version()."""
+ proposals = _get_proposals(request)
+ if proposal_id not in proposals:
+ raise HTTPException(status_code=404, detail="Proposal not found.")
+ proposal = proposals[proposal_id]
+
+ if proposal.state != "approved":
+ raise HTTPException(status_code=400, detail="Only approved proposals can be published.")
+
+ drafts = _get_drafts(request)
+ if proposal.draft_id not in drafts:
+ raise HTTPException(status_code=404, detail="Draft not found.")
+ draft = drafts[proposal.draft_id]
+
+ # Apply draft diff to live graph
+ nodes_added = 0
+ edges_added = 0
+
+ # Add new classes
+ for class_uri in draft.diff.added_classes:
+ node = {
+ "id": class_uri,
+ "type": "owl:Class",
+ "content": class_uri.rsplit("/", 1)[-1].rsplit("#", 1)[-1],
+ "properties": {"rdfs:label": class_uri.rsplit("/", 1)[-1].rsplit("#", 1)[-1]},
+ }
+ nodes_added += await asyncio.to_thread(session.add_nodes, [node])
+
+ # Build ontology dict for version creation
+ ontology_dict = {
+ "uri": proposal.ontology_uri,
+ "classes": [{"uri": uri} for uri in draft.diff.added_classes],
+ "properties": [{"uri": uri} for uri in draft.diff.added_properties],
+ "diff": draft.diff.model_dump(),
+ }
+
+ # Create version record using VersionManager
+ try:
+ from ...change_management.ontology_version_manager import VersionManager
+ from ...ontology import OntologyEngine
+
+ # Initialize VersionManager with proper config
+ version_manager = VersionManager(
+ store=session.graph.store if hasattr(session.graph, "store") else None
+ )
+
+ # Generate version string based on existing versions
+ versions = _get_versions(request)
+ existing_versions = versions.get(proposal.ontology_uri, [])
+ version_num = len(existing_versions) + 1
+ version_str = f"1.{version_num}.0"
+
+ version_record = version_manager.create_version(
+ version=version_str,
+ ontology=ontology_dict,
+ changes=[proposal.summary],
+ )
+
+ # Store version in app state
+ if proposal.ontology_uri not in versions:
+ versions[proposal.ontology_uri] = []
+
+ versions[proposal.ontology_uri].append({
+ "version_id": version_str,
+ "ontology_uri": proposal.ontology_uri,
+ "state": "published",
+ "author": proposal.author,
+ "date": datetime.now(UTC).isoformat(),
+ "diff_summary": draft.diff.model_dump(),
+ })
+
+ logger.info(f"Created version {version_str} for ontology {proposal.ontology_uri}")
+
+ except Exception as exc:
+ logger.warning(f"VersionManager.create_version failed: {exc}")
+ raise HTTPException(status_code=500, detail=f"Version creation failed: {exc}") from exc
+
+ proposal.state = "published"
+ proposal.updated_at = datetime.now(UTC).isoformat()
+
+ return {
+ "status": "published",
+ "proposal_id": proposal_id,
+ "version": version_str,
+ "nodes_added": nodes_added,
+ "edges_added": edges_added,
+ }
+
+
+@router.post("/proposals/{proposal_id}/comment")
+async def add_comment(
+ proposal_id: str,
+ body: CommentRequest,
+ request: Request,
+):
+ """Add inline comment to a proposal."""
+ proposals = _get_proposals(request)
+ if proposal_id not in proposals:
+ raise HTTPException(status_code=404, detail="Proposal not found.")
+ proposal = proposals[proposal_id]
+ comment = {
+ "id": f"comment_{uuid.uuid4().hex[:8]}",
+ "element_uri": body.element_uri,
+ "text": body.text,
+ "author": body.author,
+ "created_at": datetime.now(UTC).isoformat(),
+ }
+ proposal.comments.append(comment)
+ proposal.updated_at = datetime.now(UTC).isoformat()
+ return {"status": "commented", "comment_id": comment["id"]}
+
+
+# ---------------------------------------------------------------------------
+# Version endpoints
+# ---------------------------------------------------------------------------
+
+@router.get("/versions/{ontology_uri:path}", response_model=List[VersionEntry])
+async def list_versions(
+ ontology_uri: str,
+ request: Request,
+):
+ """List version history for an ontology."""
+ versions = _get_versions(request)
+ return versions.get(ontology_uri, [])
+
+
+@router.post("/versions/{ontology_uri:path}/compare", response_model=VersionCompareResponse)
+async def compare_versions(
+ ontology_uri: str,
+ body: VersionCompareRequest,
+ request: Request,
+ session: GraphSession = Depends(get_session),
+):
+ """Compare two ontology versions using VersionManager.compare_versions()."""
+ try:
+ from ...change_management.ontology_version_manager import VersionManager
+
+ # Initialize VersionManager with session's graph store
+ version_manager = VersionManager(
+ store=session.graph.store if hasattr(session.graph, "store") else None
+ )
+
+ # Get version data from app state
+ versions = _get_versions(request)
+ ontology_versions = versions.get(ontology_uri, [])
+
+ # Find version records
+ v1_record = next((v for v in ontology_versions if v.version_id == body.version1), None)
+ v2_record = next((v for v in ontology_versions if v.version_id == body.version2), None)
+
+ if not v1_record or not v2_record:
+ raise HTTPException(status_code=404, detail="One or both versions not found.")
+
+ # Build ontology dicts from version records
+ v1_dict = {
+ "uri": ontology_uri,
+ "version": body.version1,
+ "classes": [],
+ "properties": [],
+ "diff": v1_record.diff_summary,
+ }
+ v2_dict = {
+ "uri": ontology_uri,
+ "version": body.version2,
+ "classes": [],
+ "properties": [],
+ "diff": v2_record.diff_summary,
+ }
+
+ # Use VersionManager.diff_ontologies for structured comparison
+ diff_result = await asyncio.to_thread(
+ version_manager.diff_ontologies,
+ v1_dict,
+ v2_dict
+ )
+
+ # Use VersionManager.compare_versions for metadata comparison
+ comparison = await asyncio.to_thread(
+ version_manager.compare_versions,
+ body.version1,
+ body.version2
+ )
+
+ return VersionCompareResponse(
+ version1=body.version1,
+ version2=body.version2,
+ metadata_changes=comparison.get("metadata_changes", {}),
+ class_changes={
+ "added": diff_result.get("added_classes", []),
+ "removed": diff_result.get("removed_classes", []),
+ "changed": diff_result.get("changed_classes", []),
+ },
+ property_changes={
+ "added": diff_result.get("added_properties", []),
+ "removed": diff_result.get("removed_properties", []),
+ "changed": diff_result.get("changed_properties", []),
+ },
+ restriction_changes={
+ "added": diff_result.get("added_axioms", []),
+ "removed": diff_result.get("removed_axioms", []),
+ "changed": diff_result.get("changed_axioms", []),
+ },
+ axiom_changes={
+ "added": diff_result.get("added_axioms", []),
+ "removed": diff_result.get("removed_axioms", []),
+ "changed": diff_result.get("changed_axioms", []),
+ },
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.error(f"Version comparison failed: {exc}")
+ raise HTTPException(status_code=500, detail=f"Version comparison failed: {exc}") from exc
+
+
+# ---------------------------------------------------------------------------
+# Alignment endpoints using OntologyEngine
+# ---------------------------------------------------------------------------
+
+@router.post("/alignments", response_model=AlignmentResponse)
+async def create_alignment(
+ body: AlignmentRequest,
+ session: GraphSession = Depends(get_session),
+):
+ """Create an alignment between two ontology entities using OntologyEngine.create_alignment."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Use OntologyEngine.create_alignment
+ await asyncio.to_thread(
+ engine.create_alignment,
+ body.source_uri,
+ body.target_uri,
+ body.predicate
+ )
+
+ return AlignmentResponse(
+ source=body.source_uri,
+ predicate=body.predicate,
+ target=body.target_uri,
+ )
+ except Exception as exc:
+ logger.error(f"Failed to create alignment: {exc}")
+ raise HTTPException(status_code=500, detail=f"Alignment creation failed: {exc}") from exc
+
+
+@router.get("/alignments/{entity_uri:path}", response_model=List[AlignmentResponse])
+async def get_alignments(
+ entity_uri: str,
+ session: GraphSession = Depends(get_session),
+):
+ """Get all alignments for an entity using OntologyEngine.get_alignments."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Use OntologyEngine.get_alignments
+ alignments = await asyncio.to_thread(engine.get_alignments, entity_uri)
+
+ return [
+ AlignmentResponse(
+ source=align.get("source", ""),
+ predicate=align.get("predicate", ""),
+ target=align.get("target", ""),
+ )
+ for align in alignments
+ ]
+ except Exception as exc:
+ logger.error(f"Failed to get alignments: {exc}")
+ raise HTTPException(status_code=500, detail=f"Failed to get alignments: {exc}") from exc
+
+
+@router.get("/alignments", response_model=List[AlignmentResponse])
+async def list_alignments(
+ ontology_uri: Optional[str] = Query(None),
+ session: GraphSession = Depends(get_session),
+):
+ """List all alignments, optionally filtered by ontology URI using OntologyEngine.list_alignments."""
+ try:
+ from ...ontology import OntologyEngine
+
+ # Initialize OntologyEngine with session's graph store
+ engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
+ engine = OntologyEngine(**engine_config)
+
+ # Use OntologyEngine.list_alignments
+ alignments = await asyncio.to_thread(engine.list_alignments, ontology_uri=ontology_uri)
+
+ return [
+ AlignmentResponse(
+ source=align.get("source", ""),
+ predicate=align.get("predicate", ""),
+ target=align.get("target", ""),
+ )
+ for align in alignments
+ ]
+ except Exception as exc:
+ logger.error(f"Failed to list alignments: {exc}")
+ raise HTTPException(status_code=500, detail=f"Failed to list alignments: {exc}") from exc
diff --git a/semantica/server.py b/semantica/server.py
index 814bdc3a..45ce61e0 100644
--- a/semantica/server.py
+++ b/semantica/server.py
@@ -152,10 +152,11 @@ if EXPLORER_AVAILABLE:
enrich,
export_import,
graph,
+ ontology,
temporal,
vocabulary,
- provenance,
- sparql
+ provenance,
+ sparql
)
app.include_router(analytics.router)
@@ -164,12 +165,13 @@ if EXPLORER_AVAILABLE:
app.include_router(enrich.router)
app.include_router(export_import.router)
app.include_router(graph.router)
+ app.include_router(ontology.router)
app.include_router(temporal.router)
app.include_router(vocabulary.router)
- app.include_router(provenance.router)
- app.include_router(sparql.router)
+ app.include_router(provenance.router)
+ app.include_router(sparql.router)
- logging.info("Explorer, Vocabulary, SPARQL, and Provenance API routes successfully mounted.")
+ logging.info("Explorer, Vocabulary, SPARQL, Provenance, and Ontology API routes successfully mounted.")
except Exception as exc:
logging.error(f"Failed to mount explorer routes: {exc}")
else: