Merge pull request #524 from Hawksight-AI/feat/onto-hub-subissue-520

feat(ontology): add alignments, health dashboard, and SHACL studio
This commit is contained in:
Mohd Kaif
2026-05-02 17:21:54 +05:30
committed by GitHub
11 changed files with 2307 additions and 34 deletions
+39
View File
@@ -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 (24) 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.
+14 -2
View File
@@ -1237,6 +1237,7 @@ export default function App() {
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const [graphFocusRequest, setGraphFocusRequest] = useState<{ nodeId: string; token: number } | null>(null);
const renderWorkspace = () => {
@@ -1284,7 +1285,12 @@ export default function App() {
}
>
<Suspense fallback={<WorkspaceFallback />}>
{exploreView === 'graph' ? <GraphWorkspace /> : <VocabularyWorkspace />}
{exploreView === 'graph' ? (
<GraphWorkspace
externalFocusNodeId={graphFocusRequest?.nodeId}
externalFocusToken={graphFocusRequest?.token}
/>
) : <VocabularyWorkspace />}
</Suspense>
</WorkspaceShell>
);
@@ -1370,7 +1376,13 @@ export default function App() {
compact
>
<Suspense fallback={<WorkspaceFallback />}>
<OntologyWorkspace />
<OntologyWorkspace
onJumpToGraphNode={(nodeId: string) => {
setGraphFocusRequest({ nodeId, token: Date.now() });
setActiveWorkspace('explore');
setExploreView('graph');
}}
/>
</Suspense>
</WorkspaceShell>
);
@@ -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<Set<string>>(new Set());
const sceneRef = useRef<GraphSceneHandle>(null);
const lastExternalFocusTokenRef = useRef<number | undefined>(undefined);
const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
const settlingOverlayTimeoutRef = useRef<number | null>(null);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
@@ -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);
}, []);
@@ -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<AlignmentRelation, string> = {
"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<OntologyEntry[]>([]);
const [alignments, setAlignments] = useState<OntologyAlignment[]>([]);
const [suggestions, setSuggestions] = useState<AlignmentSuggestion[]>([]);
const [sourceOntology, setSourceOntology] = useState("");
const [targetOntology, setTargetOntology] = useState("");
const [sourceUri, setSourceUri] = useState("");
const [targetUri, setTargetUri] = useState("");
const [relation, setRelation] = useState<AlignmentRelation>("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<string, number>();
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<string, OntologyAlignment[]> = 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 (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><GitMerge size={14} /> Alignment Matrix</div>
<h2 style={titleStyle}>Cross-ontology mappings</h2>
<p style={textStyle}>
Manage equivalence and SKOS match relations with confidence, provenance,
reviewer context, and label-based suggestions.
</p>
</div>
<div style={summaryGridStyle}>
<Metric label="Mappings" value={alignments.length} />
<Metric label="Relations" value={relationCounts.size} />
<Metric label="Suggestions" value={suggestions.length} />
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
<div style={ephemeralBannerStyle}>
Alignments are stored in server memory and are not persisted across restarts.
Export your graph or ontology to preserve recorded mappings.
</div>
{matrix.ontologies.length >= 2 ? (
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Pairwise alignment matrix</h3>
<div style={{ overflowX: "auto" }}>
<table style={matrixTableStyle}>
<thead>
<tr>
<th style={matrixCornerStyle} />
{matrix.ontologies.map((col) => (
<th key={col.uri} style={matrixColHeaderStyle}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{matrix.ontologies.map((row) => (
<tr key={row.uri}>
<td style={matrixRowHeaderStyle}>{row.name}</td>
{matrix.ontologies.map((col) => {
const key = `${row.uri}|||${col.uri}`;
const cellItems = matrix.cells.get(key) ?? [];
const isDiag = row.uri === col.uri;
return (
<td key={col.uri} style={{ ...matrixCellStyle, background: isDiag ? "rgba(255,255,255,0.015)" : undefined }}>
{isDiag ? <span style={mutedStyle}></span> : cellItems.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{cellItems.map((item) => (
<span
key={item.id}
style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}44`, cursor: "pointer", fontSize: 9 }}
title={`${item.source_label}${item.target_label} (${Math.round(item.confidence * 100)}%)`}
onClick={() => {
setSourceUri(item.source_uri);
setTargetUri(item.target_uri);
setRelation(item.relation);
setConfidence(item.confidence);
setProvenance(item.provenance ?? "");
}}
>
{item.relation.split(":")[1]}
</span>
))}
</div>
) : <span style={{ color: "rgba(127,208,255,0.15)", fontSize: 12 }}>·</span>}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p style={{ ...mutedStyle, marginTop: 10 }}>Click a relation badge to load it into the editor below.</p>
</section>
) : null}
<div style={gridStyle}>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Create or update alignment</h3>
<label style={labelStyle}>Source entity URI</label>
<input style={inputStyle} value={sourceUri} onChange={(event) => setSourceUri(event.target.value)} />
<label style={labelStyle}>Target entity URI</label>
<input style={inputStyle} value={targetUri} onChange={(event) => setTargetUri(event.target.value)} />
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Relation</label>
<select style={inputStyle} value={relation} onChange={(event) => setRelation(event.target.value as AlignmentRelation)}>
{RELATIONS.map((item) => <option key={item}>{item}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Confidence {confidence.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={confidence}
onChange={(event) => setConfidence(Number(event.target.value))}
style={{ width: "100%" }}
/>
</div>
</div>
<label style={labelStyle}>Provenance note</label>
<textarea style={{ ...inputStyle, minHeight: 74, resize: "vertical" }} value={provenance} onChange={(event) => setProvenance(event.target.value)} />
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Source</label>
<input style={inputStyle} value={source} onChange={(event) => setSource(event.target.value)} />
</div>
<div>
<label style={labelStyle}>Reviewer</label>
<input style={inputStyle} value={reviewer} onChange={(event) => setReviewer(event.target.value)} />
</div>
</div>
<button style={primaryButtonStyle} disabled={busy} onClick={handleSave}>
{busy ? <Loader2 size={14} className="spin" /> : <GitMerge size={14} />}
Save alignment
</button>
</section>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Suggest alignments</h3>
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Source ontology</label>
<select style={inputStyle} value={sourceOntology} onChange={(event) => setSourceOntology(event.target.value)}>
<option value="">Any ontology</option>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Target ontology</label>
<select style={inputStyle} value={targetOntology} onChange={(event) => setTargetOntology(event.target.value)}>
<option value="">Any ontology</option>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</div>
<label style={labelStyle}>Similarity threshold {threshold.toFixed(2)}</label>
<input
type="range"
min="0.25"
max="0.95"
step="0.01"
value={threshold}
onChange={(event) => setThreshold(Number(event.target.value))}
style={{ width: "100%" }}
/>
<button style={secondaryButtonStyle} disabled={busy} onClick={handleSuggest}>
<Sparkles size={14} />
Suggest alignments
</button>
<div style={suggestionListStyle}>
{suggestions.map((item) => (
<button key={`${item.source_uri}-${item.target_uri}-${item.relation}`} style={suggestionStyle} onClick={() => handleAcceptSuggestion(item)}>
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label}</span>
<span style={{ color: RELATION_COLORS[item.relation] }}>{item.relation}</span>
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label}</span>
<span style={{ color: "#8fa8c6" }}>{Math.round(item.score * 100)}%</span>
</button>
))}
{!suggestions.length ? <p style={mutedStyle}>Run suggestions to review ranked candidate mappings.</p> : null}
</div>
</section>
</div>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Recorded alignments</h3>
<div style={tableStyle}>
{alignments.map((item) => (
<div key={item.id} style={rowStyle}>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label || item.source_uri}</div>
<div style={monoStyle}>{item.source_uri}</div>
</div>
<div style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}55` }}>
{item.relation}
</div>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label || item.target_uri}</div>
<div style={monoStyle}>{item.target_uri}</div>
</div>
<div style={confidenceStyle}>{Math.round(item.confidence * 100)}%</div>
<button style={iconButtonStyle} disabled={busy} onClick={() => handleRemove(item.id)} title="Remove alignment">
<Trash2 size={14} />
</button>
</div>
))}
{!alignments.length ? <p style={mutedStyle}>No alignments recorded yet.</p> : null}
</div>
</section>
</div>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div style={metricStyle}>
<span style={{ color: "#9ee8d7", fontSize: 20, fontWeight: 900 }}>{value.toLocaleString()}</span>
<span style={{ color: "#6a7f97", fontSize: 11 }}>{label}</span>
</div>
);
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
const summaryGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(100px, 1fr))", gap: 10, minWidth: 320 };
const metricStyle: CSSProperties = { padding: 14, borderRadius: 16, background: "rgba(255,255,255,0.035)", border: "1px solid rgba(127,208,255,0.1)", display: "flex", flexDirection: "column", gap: 4 };
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "minmax(320px, 0.9fr) minmax(360px, 1.1fr)", gap: 16 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)" };
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "10px 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const twoColStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 };
const primaryButtonStyle: CSSProperties = { marginTop: 14, width: "100%", border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "11px 13px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8 };
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
const suggestionListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8, marginTop: 14, maxHeight: 260, overflow: "auto" };
const suggestionStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto", gap: 10, alignItems: "center", textAlign: "left", border: "1px solid rgba(127,208,255,0.1)", borderRadius: 12, background: "rgba(255,255,255,0.03)", padding: 10, cursor: "pointer" };
const tableStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const rowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto auto", gap: 12, alignItems: "center", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const monoStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const relationBadgeStyle: CSSProperties = { padding: "5px 9px", border: "1px solid", borderRadius: 999, fontSize: 10, fontWeight: 900 };
const confidenceStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
const iconButtonStyle: CSSProperties = { width: 34, height: 34, borderRadius: 10, border: "1px solid rgba(255,157,175,0.18)", background: "rgba(255,157,175,0.08)", color: "#ff9daf", cursor: "pointer" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
const ephemeralBannerStyle: CSSProperties = { padding: "9px 14px", borderRadius: 12, color: "#f2b66d", background: "rgba(242,182,109,0.08)", border: "1px solid rgba(242,182,109,0.22)", fontSize: 12 };
const matrixTableStyle: CSSProperties = { borderCollapse: "collapse", minWidth: "100%", fontSize: 12 };
const matrixCornerStyle: CSSProperties = { padding: "8px 12px", borderBottom: "1px solid rgba(127,208,255,0.1)" };
const matrixColHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderBottom: "1px solid rgba(127,208,255,0.1)", textAlign: "center", whiteSpace: "nowrap" };
const matrixRowHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderRight: "1px solid rgba(127,208,255,0.1)", whiteSpace: "nowrap" };
const matrixCellStyle: CSSProperties = { padding: "8px 10px", borderBottom: "1px solid rgba(127,208,255,0.06)", borderRight: "1px solid rgba(127,208,255,0.06)", textAlign: "center", verticalAlign: "middle", minWidth: 100 };
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { Download, HeartPulse, Loader2, Wrench } from "lucide-react";
import { loadOntologyHealth, loadOntologyRegistry } from "./api";
import type { OntologyEntry, OntologyHealthResponse, HealthIssue } from "./types";
interface HealthTabProps {
onFixInEditor?: (entityUri: string) => void;
}
export function HealthTab({ onFixInEditor }: HealthTabProps) {
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
const [selectedUri, setSelectedUri] = useState("");
const [health, setHealth] = useState<OntologyHealthResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
loadOntologyRegistry()
.then((entries) => {
if (cancelled) return;
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
return () => {
cancelled = true;
};
}, []);
const loadHealth = useCallback(async (uri: string) => {
if (!uri) return;
setLoading(true);
setError("");
try {
setHealth(await loadOntologyHealth(uri));
} catch (err) {
setHealth(null);
setError(err instanceof Error ? err.message : "Could not load ontology health.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadHealth(selectedUri);
}, [selectedUri, loadHealth]);
const exportReport = useCallback(() => {
if (!health) return;
const blob = new Blob([JSON.stringify(health, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${health.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-health.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(url), 100);
}, [health]);
return (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><HeartPulse size={14} /> Ontology Health</div>
<h2 style={titleStyle}>Quality and governance signals</h2>
<p style={textStyle}>
Score completeness, consistency, SHACL readiness, alignment coverage,
and documentation quality for the selected ontology.
</p>
</div>
<div style={selectorShellStyle}>
<label style={labelStyle}>Ontology</label>
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
{loading ? (
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
) : health ? (
<>
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
<div style={scoreCardStyle}>
<span style={scoreValueStyle}>{Math.round(health.total_score)}</span>
<span style={mutedStyle}>Total health score</span>
<button style={secondaryButtonStyle} onClick={exportReport}><Download size={14} /> Export report</button>
</div>
{health.dimensions.map((dimension) => (
<div key={dimension.key} style={dimensionCardStyle}>
<div style={dimensionHeadStyle}>
<span style={{ color: "#ebf3ff", fontWeight: 900 }}>{dimension.label}</span>
<span style={statusBadgeStyle(dimension.status)}>{dimension.status}</span>
</div>
<div style={barTrackStyle}>
<div style={{ ...barFillStyle, width: `${dimension.score}%`, background: dimensionColor(dimension.score, dimension.status) }} />
</div>
<div style={dimensionFootStyle}>
<span>{Math.round(dimension.score)} / 100</span>
<span>{dimension.detail}</span>
</div>
</div>
))}
</section>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Actionable issues</h3>
<div style={issueListStyle}>
{health.issues.map((issue) => (
<IssueRow key={issue.id} issue={issue} onFixInEditor={onFixInEditor} />
))}
{!health.issues.length ? <p style={mutedStyle}>No actionable issues reported for this ontology.</p> : null}
</div>
</section>
</>
) : (
<div style={emptyStyle}>Select an ontology to compute health signals.</div>
)}
</div>
);
}
function IssueRow({ issue, onFixInEditor }: { issue: HealthIssue; onFixInEditor?: (entityUri: string) => void }) {
return (
<div style={issueRowStyle}>
<div style={severityDotStyle(issue.severity)} />
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{issue.entity_label || issue.category}</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.45 }}>{issue.message}</div>
{issue.entity_uri ? <div style={monoStyle}>{issue.entity_uri}</div> : null}
</div>
<span style={categoryStyle}>{issue.category}</span>
{issue.entity_uri ? (
<button style={smallButtonStyle} onClick={() => onFixInEditor?.(issue.entity_uri || "")}>
<Wrench size={13} />
Fix in Editor
</button>
) : (
<div />
)}
</div>
);
}
function dimensionColor(score: number, status: string) {
if (status === "unavailable") return "#6a7f97";
if (score >= 80) return "#7ce7d3";
if (score >= 55) return "#f2b66d";
return "#ff9daf";
}
function statusBadgeStyle(status: string): CSSProperties {
const color = status === "ok" ? "#7ce7d3" : status === "unavailable" ? "#6a7f97" : "#f2b66d";
return {
color,
background: `${color}18`,
border: `1px solid ${color}30`,
borderRadius: 999,
padding: "2px 7px",
fontSize: 10,
fontWeight: 900,
textTransform: "uppercase",
};
}
function severityDotStyle(severity: string): CSSProperties {
const color = severity === "critical" ? "#ff9daf" : severity === "warning" ? "#f2b66d" : "#58a6ff";
return { width: 10, height: 10, borderRadius: "50%", background: color, boxShadow: `0 0 18px ${color}55`, marginTop: 5 };
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
const selectorShellStyle: CSSProperties = { minWidth: 320 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const scoreGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "220px repeat(5, minmax(180px, 1fr))", gap: 12 };
const scoreCardStyle: CSSProperties = { padding: 18, borderRadius: 20, background: "rgba(15,35,52,0.88)", border: "1px solid rgba(124,231,211,0.2)", display: "flex", flexDirection: "column", gap: 10 };
const scoreValueStyle: CSSProperties = { color: "#9ee8d7", fontSize: 52, lineHeight: 1, fontWeight: 950, letterSpacing: "-0.06em" };
const dimensionCardStyle: CSSProperties = { padding: 16, borderRadius: 18, background: "rgba(9,19,34,0.78)", border: "1px solid rgba(127,208,255,0.12)" };
const dimensionHeadStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center", marginBottom: 12 };
const barTrackStyle: CSSProperties = { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" };
const barFillStyle: CSSProperties = { height: "100%", borderRadius: 999 };
const dimensionFootStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, color: "#8fa8c6", fontSize: 12, marginTop: 10 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
const issueListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const issueRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "14px 1fr auto auto", gap: 12, alignItems: "start", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const categoryStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.22)", borderRadius: 999, padding: "4px 8px", fontSize: 10, fontWeight: 900 };
const smallButtonStyle: CSSProperties = { display: "inline-flex", gap: 6, alignItems: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
const secondaryButtonStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", justifyContent: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 12, padding: "10px 12px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 900 };
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
const loadingStyle: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 8, color: "#8fa8c6", padding: 18 };
const emptyStyle: CSSProperties = { color: "#6a7f97", padding: 22 };
const errorStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
@@ -0,0 +1,346 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import Editor, { type Monaco } from "@monaco-editor/react";
import { FileCode2, Loader2, Play, Shield, Wand2 } from "lucide-react";
import {
generateShacl,
loadOntologyRegistry,
loadShaclShapes,
validateShacl,
} from "./api";
import type { OntologyEntry, ShaclShapeSummary, ShaclValidationResponse } from "./types";
interface ShaclStudioProps {
onJumpToNode?: (nodeId: string) => void;
}
export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
const [selectedUri, setSelectedUri] = useState("");
const [shacl, setShacl] = useState("");
const [fullShacl, setFullShacl] = useState(""); // preserves complete Turtle across shape selections
const [shapes, setShapes] = useState<ShaclShapeSummary[]>([]);
const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null);
const [validation, setValidation] = useState<ShaclValidationResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
loadOntologyRegistry()
.then((entries) => {
if (cancelled) return;
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
return () => {
cancelled = true;
};
}, []);
const loadShapes = useCallback(async (uri: string) => {
if (!uri) return;
setLoading(true);
setError("");
try {
const data = await loadShaclShapes(uri);
setShapes(data.shapes);
const turtle = data.shacl_turtle;
setFullShacl(turtle);
setShacl((current) => current || turtle);
setSelectedShapeId(null);
setValidation(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load SHACL shapes.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setShacl("");
setFullShacl("");
setSelectedShapeId(null);
void loadShapes(selectedUri);
}, [selectedUri, loadShapes]);
const handleGenerate = useCallback(async () => {
if (!selectedUri) return;
setLoading(true);
setError("");
try {
const data = await generateShacl(selectedUri, "strict");
setFullShacl(data.shacl_turtle);
setShacl(data.shacl_turtle);
setSelectedShapeId(null);
const shapeData = await loadShaclShapes(selectedUri);
setShapes(shapeData.shapes);
setValidation(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not generate SHACL.");
} finally {
setLoading(false);
}
}, [selectedUri]);
const handleSelectShape = useCallback((shapeId: string) => {
setSelectedShapeId(shapeId);
// Extract the Turtle block for this shape from the full SHACL so the editor
// pre-populates with the selected shape's definition.
const src = fullShacl || shacl;
const normalised = src.replace(/\r\n/g, "\n");
// Split on blank lines to isolate statement groups.
const blocks = normalised.split(/\n{2,}/).filter((b) => b.trim());
const match = blocks.find((b) => {
const first = b.trimStart();
return first.startsWith(shapeId + " ") || first.startsWith(shapeId + "\n") || first.startsWith(shapeId + "\t");
});
if (match) {
setShacl(match.trim());
}
}, [fullShacl, shacl]);
const handleShowAllShapes = useCallback(() => {
setSelectedShapeId(null);
setShacl(fullShacl);
}, [fullShacl]);
const handleValidate = async () => {
if (!selectedUri || !shacl.trim()) return;
setLoading(true);
setError("");
try {
setValidation(await validateShacl(selectedUri, shacl));
} catch (err) {
setError(err instanceof Error ? err.message : "Could not validate SHACL.");
} finally {
setLoading(false);
}
};
const groupedShapes = useMemo(() => {
const groups = new Map<string, ShaclShapeSummary[]>();
for (const shape of shapes) {
const key = shape.target_class || "Untargeted shapes";
groups.set(key, [...(groups.get(key) || []), shape]);
}
return Array.from(groups.entries());
}, [shapes]);
const beforeMount = useCallback((monaco: Monaco) => {
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
monaco.languages.setMonarchTokensProvider("turtle", {
keywords: ["@prefix", "@base", "a"],
tokenizer: {
root: [
[/#[^\n]*/, "comment"],
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/"""[\s\S]*?"""/, "string"],
[/<[^>]*>/, "type.identifier"],
[/\b(?:@prefix|@base|a)\b/, "keyword"],
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
[/[;,.]/, "delimiter"],
[/\d+(?:\.\d+)?/, "number"],
],
},
});
}
monaco.editor.defineTheme("shacl-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "9ee8d7" },
{ token: "string", foreground: "f2b66d" },
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
{ token: "type.identifier", foreground: "7ce7d3" },
{ token: "variable", foreground: "d2a8ff" },
{ token: "namespace", foreground: "a5d6ff" },
{ token: "number", foreground: "79c0ff" },
{ token: "delimiter", foreground: "8fa8c6" },
],
colors: {
"editor.background": "#050b13",
"editor.foreground": "#d7e7f8",
"editorLineNumber.foreground": "#41536b",
},
});
}, []);
return (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><Shield size={14} /> SHACL Studio</div>
<h2 style={titleStyle}>Generate, edit, and validate shapes</h2>
<p style={textStyle}>
Create strict SHACL Turtle from ontology structure, inspect shape targets,
run validation, and jump from violations back into the graph.
</p>
</div>
<div style={selectorShellStyle}>
<label style={labelStyle}>Ontology</label>
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
<div style={gridStyle}>
<section style={cardStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>Shape library</h3>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={countBadgeStyle}>{shapes.length} shapes</span>
{selectedShapeId ? (
<button style={smallButtonStyle} onClick={handleShowAllShapes}>View all</button>
) : null}
</div>
</div>
<div style={shapeListStyle}>
{groupedShapes.map(([target, items]) => (
<div key={target} style={shapeGroupStyle}>
<div style={shapeTargetStyle}>{target}</div>
{items.map((shape) => {
const isSelected = selectedShapeId === shape.id;
return (
<button
key={shape.id}
style={{
...shapeRowStyle,
cursor: "pointer",
background: isSelected ? "rgba(124,231,211,0.1)" : "rgba(255,255,255,0.03)",
border: isSelected ? "1px solid rgba(124,231,211,0.35)" : "1px solid rgba(127,208,255,0.08)",
textAlign: "left",
width: "100%",
}}
onClick={() => handleSelectShape(shape.id)}
title="Click to load this shape into the editor"
>
<FileCode2 size={14} color={isSelected ? "#7ce7d3" : "#9ee8d7"} />
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{shape.id}</div>
<div style={mutedStyle}>
{shape.constraint_count} constraints
{shape.constraints.length ? ` · ${shape.constraints.join(", ")}` : ""}
</div>
</div>
<span style={violationBadgeStyle}>{shape.violation_count}</span>
</button>
);
})}
</div>
))}
{!shapes.length ? <p style={mutedStyle}>No shapes generated yet.</p> : null}
</div>
</section>
<section style={editorShellStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>
{selectedShapeId ? selectedShapeId : "Turtle shape editor"}
</h3>
<div style={{ display: "flex", gap: 8 }}>
<button style={secondaryButtonStyle} disabled={loading} onClick={handleGenerate}><Wand2 size={14} /> Generate strict</button>
<button style={primaryButtonStyle} disabled={loading || !shacl.trim()} onClick={handleValidate}>
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
Validate
</button>
</div>
</div>
<div style={editorFrameStyle}>
<Editor
height="100%"
language="turtle"
theme="shacl-dark"
beforeMount={beforeMount}
value={shacl}
onChange={(value) => setShacl(value || "")}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "JetBrains Mono, monospace",
wordWrap: "on",
scrollBeyondLastLine: false,
}}
/>
</div>
</section>
</div>
<section style={cardStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>Validation report</h3>
{validation ? <span style={validationBadgeStyle(validation.status, validation.conforms)}>{validation.status}{validation.conforms ? " · conforms" : ""}</span> : null}
</div>
{validation ? (
<>
<p style={textStyle}>{validation.message}</p>
<div style={shapeListStyle}>
{validation.violations.map((violation, index) => {
const nodeId = violation.focus_node || violation.node;
return (
<div key={`${violation.node}-${violation.path}-${index}`} style={violationRowStyle}>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{violation.message}</div>
<div style={mutedStyle}>{violation.severity} {violation.path ? `· ${violation.path}` : ""}</div>
{nodeId ? <div style={monoStyle}>{nodeId}</div> : null}
</div>
{nodeId ? (
<button style={smallButtonStyle} onClick={() => onJumpToNode?.(nodeId)}>
Jump to Node
</button>
) : null}
</div>
);
})}
{!validation.violations.length ? <p style={mutedStyle}>No validation violations returned.</p> : null}
</div>
</>
) : (
<p style={mutedStyle}>Generate or edit SHACL Turtle, then run validation.</p>
)}
</section>
</div>
);
}
function validationBadgeStyle(status: string, conforms: boolean): CSSProperties {
const color = status === "unavailable" ? "#f2b66d" : conforms ? "#7ce7d3" : "#ff9daf";
return { color, border: `1px solid ${color}35`, background: `${color}14`, borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900, textTransform: "uppercase" };
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 680 };
const selectorShellStyle: CSSProperties = { minWidth: 320 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "360px minmax(0, 1fr)", gap: 16, minHeight: 560 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
const editorShellStyle: CSSProperties = { ...cardStyle, display: "flex", flexDirection: "column", minHeight: 560 };
const panelHeaderStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 12 };
const sectionTitleStyle: CSSProperties = { margin: 0, color: "#ebf3ff", fontSize: 16 };
const countBadgeStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.2)", borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900 };
const shapeListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const shapeGroupStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6 };
const shapeTargetStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontWeight: 900, textTransform: "uppercase", letterSpacing: "0.08em" };
const shapeRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "18px 1fr auto", gap: 10, padding: 10, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const violationBadgeStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
const editorFrameStyle: CSSProperties = { flex: 1, minHeight: 0, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 16, overflow: "hidden" };
const primaryButtonStyle: CSSProperties = { border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "9px 11px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 };
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
const violationRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto", gap: 12, padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const smallButtonStyle: CSSProperties = { border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 12, lineHeight: 1.5 };
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
@@ -0,0 +1,104 @@
import type {
AlignmentRelation,
AlignmentSuggestion,
OntologyAlignment,
OntologyEntry,
OntologyHealthResponse,
ShaclGenerateResponse,
ShaclShapesResponse,
ShaclValidationResponse,
} from "./types";
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let detail = `Request failed with status ${response.status}`;
try {
const body = await response.json();
detail = body.detail || detail;
} catch {
// Keep the generic HTTP detail.
}
throw new Error(detail);
}
return response.json() as Promise<T>;
}
export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
}
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
}
export async function saveAlignment(payload: {
source_uri: string;
target_uri: string;
relation: AlignmentRelation;
confidence: number;
provenance?: string;
source?: string;
reviewer?: string;
}): Promise<OntologyAlignment> {
return parseResponse<OntologyAlignment>(
await fetch("/api/ontology/alignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function removeAlignment(id: string): Promise<void> {
await parseResponse<{ status: string }>(
await fetch(`/api/ontology/alignments?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
);
}
export async function suggestAlignments(payload: {
source_ontology_uri?: string;
target_ontology_uri?: string;
threshold: number;
limit: number;
}): Promise<AlignmentSuggestion[]> {
return parseResponse<AlignmentSuggestion[]>(
await fetch("/api/ontology/suggest-alignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function loadOntologyHealth(uri: string): Promise<OntologyHealthResponse> {
return parseResponse<OntologyHealthResponse>(
await fetch(`/api/ontology/health?uri=${encodeURIComponent(uri)}`),
);
}
export async function generateShacl(uri: string, qualityTier: "standard" | "strict" = "strict"): Promise<ShaclGenerateResponse> {
return parseResponse<ShaclGenerateResponse>(
await fetch("/api/ontology/shacl/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, quality_tier: qualityTier }),
}),
);
}
export async function loadShaclShapes(uri: string): Promise<ShaclShapesResponse> {
return parseResponse<ShaclShapesResponse>(
await fetch(`/api/ontology/shacl/shapes?uri=${encodeURIComponent(uri)}`),
);
}
export async function validateShacl(uri: string, shaclTurtle: string): Promise<ShaclValidationResponse> {
return parseResponse<ShaclValidationResponse>(
await fetch("/api/ontology/shacl/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, shacl_turtle: shaclTurtle }),
}),
);
}
@@ -7,8 +7,11 @@ import {
Shield,
Sliders,
} from "lucide-react";
import { AlignmentsTab } from "./AlignmentsTab";
import { HealthTab } from "./HealthTab";
import { OntologyManager } from "./OntologyManager";
import { OntologyEditor } from "./OntologyEditor";
import { ShaclStudio } from "./ShaclStudio";
import { VersionsTab } from "./VersionsTab";
export type OntologyHubTab =
@@ -78,7 +81,11 @@ function ComingSoonStub({
);
}
export function OntologyWorkspace() {
interface OntologyWorkspaceProps {
onJumpToGraphNode?: (nodeId: string) => void;
}
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
useEffect(() => {
@@ -89,6 +96,14 @@ export function OntologyWorkspace() {
setActiveTab(tab);
}, []);
const handleFixInEditor = useCallback((entityUri: string) => {
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, "editor");
params.set("ontologyEntity", entityUri);
window.history.replaceState(null, "", `?${params.toString()}`);
setActiveTab("editor");
}, []);
const renderTab = () => {
switch (activeTab) {
case "registry":
@@ -98,32 +113,11 @@ export function OntologyWorkspace() {
case "versions":
return <VersionsTab />;
case "alignments":
return (
<ComingSoonStub
icon={GitMerge}
title="Cross-Ontology Alignments"
description="Manage mappings between ontologies, review suggested alignments from embedding-assisted similarity, and publish alignment sets."
badge="Subissue 3"
/>
);
return <AlignmentsTab />;
case "health":
return (
<ComingSoonStub
icon={HeartPulse}
title="Ontology Health Dashboard"
description="Score completeness, consistency, SHACL conformance, alignment coverage, and documentation quality across all loaded ontologies."
badge="Subissue 3"
/>
);
return <HealthTab onFixInEditor={handleFixInEditor} />;
case "shacl":
return (
<ComingSoonStub
icon={Shield}
title="SHACL Studio"
description="Generate, edit, and validate SHACL shapes. Preview constraint violations against the active graph before publishing."
badge="Subissue 3"
/>
);
return <ShaclStudio onJumpToNode={onJumpToGraphNode} />;
}
};
@@ -0,0 +1,119 @@
export interface OntologyEntry {
uri: string;
name: string;
description?: string;
format: string;
status: "published" | "draft" | "external";
source_url?: string;
version?: string;
class_count: number;
concept_count: number;
property_count: number;
loaded_at: string;
enabled: boolean;
tags: string[];
}
export type AlignmentRelation =
| "owl:equivalentClass"
| "owl:equivalentProperty"
| "skos:exactMatch"
| "skos:closeMatch"
| "skos:broadMatch"
| "skos:narrowMatch"
| "skos:relatedMatch";
export interface OntologyAlignment {
id: string;
source_uri: string;
source_label: string;
target_uri: string;
target_label: string;
relation: AlignmentRelation;
predicate_uri: string;
confidence: number;
provenance?: string;
source?: string;
reviewer?: string;
created_at: string;
updated_at: string;
}
export interface AlignmentSuggestion {
source_uri: string;
source_label: string;
target_uri: string;
target_label: string;
relation: AlignmentRelation;
score: number;
label_similarity: number;
embedding_similarity?: number | null;
reason: string;
}
export interface HealthDimension {
key: string;
label: string;
score: number;
status: "ok" | "warning" | "critical" | "unavailable";
detail: string;
}
export interface HealthIssue {
id: string;
severity: "info" | "warning" | "critical";
category: string;
entity_uri?: string;
entity_label?: string;
message: string;
action?: string;
}
export interface OntologyHealthResponse {
uri: string;
name: string;
total_score: number;
dimensions: HealthDimension[];
issues: HealthIssue[];
generated_at: string;
}
export interface ShaclShapeSummary {
id: string;
target_class?: string;
constraint_count: number;
constraints: string[];
violation_count: number;
}
export interface ShaclViolation {
node?: string;
path?: string;
severity: string;
message: string;
focus_node?: string;
source_shape?: string;
}
export interface ShaclGenerateResponse {
uri: string;
shacl_turtle: string;
shape_count: number;
generated_at: string;
}
export interface ShaclShapesResponse {
uri: string;
shapes: ShaclShapeSummary[];
shacl_turtle: string;
generated_at: string;
}
export interface ShaclValidationResponse {
uri?: string;
conforms: boolean;
status: "success" | "unavailable" | "error";
message: string;
violations: ShaclViolation[];
report_text?: string;
}
+768 -6
View File
@@ -9,6 +9,7 @@ import logging
import socket
import uuid
from datetime import datetime, UTC
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from typing_extensions import Literal
@@ -32,6 +33,8 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ontology", tags=["ontology"])
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
_MAX_ANALYSIS_NODES = 5_000 # cap for health/suggest/shacl node scans to avoid OOM
_MAX_ENTITIES_PER_SIDE = 500 # per-ontology cap for the O(n²) pairwise suggestion loop
_CLASS_TYPES = frozenset({
"owl:Class", "rdfs:Class",
@@ -83,6 +86,16 @@ _FORMAT_ALIASES: Dict[str, str] = {
"json": "json-ld",
}
_ALIGNMENT_RELATIONS: Dict[str, str] = {
"owl:equivalentClass": "http://www.w3.org/2002/07/owl#equivalentClass",
"owl:equivalentProperty": "http://www.w3.org/2002/07/owl#equivalentProperty",
"skos:exactMatch": "http://www.w3.org/2004/02/skos/core#exactMatch",
"skos:closeMatch": "http://www.w3.org/2004/02/skos/core#closeMatch",
"skos:broadMatch": "http://www.w3.org/2004/02/skos/core#broadMatch",
"skos:narrowMatch": "http://www.w3.org/2004/02/skos/core#narrowMatch",
"skos:relatedMatch": "http://www.w3.org/2004/02/skos/core#relatedMatch",
}
_INGEST_FORMAT_SUFFIXES: Dict[str, str] = {
"turtle": ".ttl",
"xml": ".rdf",
@@ -229,6 +242,141 @@ class RefreshResponse(BaseModel):
edges_added: int = 0
AlignmentRelation = Literal[
"owl:equivalentClass",
"owl:equivalentProperty",
"skos:exactMatch",
"skos:closeMatch",
"skos:broadMatch",
"skos:narrowMatch",
"skos:relatedMatch",
]
class OntologyAlignment(BaseModel):
id: str
source_uri: str
source_label: str = ""
target_uri: str
target_label: str = ""
relation: AlignmentRelation
predicate_uri: str
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
provenance: Optional[str] = None
source: Optional[str] = None
reviewer: Optional[str] = None
created_at: str
updated_at: str
class OntologyAlignmentRequest(BaseModel):
source_uri: str
source_label: Optional[str] = None # override for external/unloaded URIs
target_uri: str
target_label: Optional[str] = None # override for external/unloaded URIs
relation: AlignmentRelation
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
provenance: Optional[str] = None
source: Optional[str] = None
reviewer: Optional[str] = None
class AlignmentSuggestionRequest(BaseModel):
source_ontology_uri: Optional[str] = None
target_ontology_uri: Optional[str] = None
threshold: float = Field(default=0.65, ge=0.0, le=1.0)
limit: int = Field(default=25, ge=1, le=100)
class AlignmentSuggestion(BaseModel):
source_uri: str
source_label: str
target_uri: str
target_label: str
relation: AlignmentRelation
score: float
label_similarity: float
embedding_similarity: Optional[float] = None
reason: str
class HealthDimension(BaseModel):
key: str
label: str
score: float = Field(ge=0.0, le=100.0)
status: Literal["ok", "warning", "critical", "unavailable"] = "ok"
detail: str
class HealthIssue(BaseModel):
id: str
severity: Literal["info", "warning", "critical"] = "warning"
category: str
entity_uri: Optional[str] = None
entity_label: Optional[str] = None
message: str
action: Optional[str] = None
class OntologyHealthResponse(BaseModel):
uri: str
name: str
total_score: float = Field(ge=0.0, le=100.0)
dimensions: List[HealthDimension]
issues: List[HealthIssue] = Field(default_factory=list)
generated_at: str
class ShaclGenerateRequest(BaseModel):
uri: str
quality_tier: Literal["standard", "strict"] = "strict"
class ShaclValidateRequest(BaseModel):
uri: Optional[str] = None
shacl_turtle: str
class ShaclViolation(BaseModel):
node: Optional[str] = None
path: Optional[str] = None
severity: str = "Violation"
message: str
focus_node: Optional[str] = None
source_shape: Optional[str] = None
class ShaclShapeSummary(BaseModel):
id: str
target_class: Optional[str] = None
constraint_count: int = 0
constraints: List[str] = Field(default_factory=list)
violation_count: int = 0
class ShaclGenerateResponse(BaseModel):
uri: str
shacl_turtle: str
shape_count: int
generated_at: str
class ShaclShapesResponse(BaseModel):
uri: str
shapes: List[ShaclShapeSummary]
shacl_turtle: str
generated_at: str
class ShaclValidationResponse(BaseModel):
uri: Optional[str] = None
conforms: bool
status: Literal["success", "unavailable", "error"] = "success"
message: str
violations: List[ShaclViolation] = Field(default_factory=list)
report_text: Optional[str] = None
# ---------------------------------------------------------------------------
# Draft, Proposal, and Version Schemas
# ---------------------------------------------------------------------------
@@ -343,6 +491,12 @@ def _get_registry(request: Request) -> Dict[str, OntologyEntry]:
return request.app.state.ontology_registry
def _get_alignment_store(request: Request) -> Dict[str, OntologyAlignment]:
if not hasattr(request.app.state, "ontology_alignments"):
request.app.state.ontology_alignments = {}
return request.app.state.ontology_alignments
def _get_drafts(request: Request) -> Dict[str, DraftResponse]:
if not hasattr(request.app.state, "ontology_drafts"):
request.app.state.ontology_drafts = {}
@@ -361,12 +515,6 @@ def _get_versions(request: Request) -> Dict[str, List[VersionEntry]]:
return request.app.state.ontology_versions
def _get_alignment_store(request: Request) -> Dict[str, AlignmentResponse]:
if not hasattr(request.app.state, "ontology_alignment_store"):
request.app.state.ontology_alignment_store = {}
return request.app.state.ontology_alignment_store
def _alignment_key(source: str, predicate: str, target: str) -> str:
return f"{source}::{predicate}::{target}"
@@ -522,6 +670,218 @@ def _extract_namespace(uri: str) -> Optional[str]:
return None
def _alignment_id(source_uri: str, relation: str, target_uri: str) -> str:
key = f"{source_uri}|{relation}|{target_uri}"
return str(uuid.uuid5(uuid.NAMESPACE_OID, key))
def _label_from_uri(uri: str) -> str:
"""Derive a readable label from a URI when no graph node is present (e.g. external vocabularies)."""
fragment = uri.rsplit("#", 1)[-1] if "#" in uri else uri.rsplit("/", 1)[-1]
return fragment or uri
def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
props = node.get("properties", {})
return (
props.get("scheme_uri")
or props.get("source_ontology")
or props.get("ontology_uri")
or props.get("ontology")
)
def _node_belongs_to_ontology(node: Dict[str, Any], ontology_uri: str) -> bool:
nid = node.get("id", "")
if nid == ontology_uri:
return True
if _node_source_ontology(node) == ontology_uri:
return True
namespace = _extract_namespace(ontology_uri)
return bool(namespace and nid.startswith(namespace))
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
return _classify_node_type(node.get("type", "")) in {"class", "property", "concept", "scheme"}
def _entity_description(node: Dict[str, Any]) -> Optional[str]:
props = node.get("properties", {})
return (
props.get("rdfs:comment")
or props.get("skos:definition")
or props.get("definition")
or props.get("description")
)
def _entity_definition(node: Dict[str, Any]) -> Optional[str]:
props = node.get("properties", {})
return props.get("skos:definition") or props.get("definition")
def _label_similarity(left: str, right: str) -> float:
left_norm = " ".join(left.lower().replace("_", " ").replace("-", " ").split())
right_norm = " ".join(right.lower().replace("_", " ").replace("-", " ").split())
if not left_norm or not right_norm:
return 0.0
sequence = SequenceMatcher(None, left_norm, right_norm).ratio()
left_tokens = set(left_norm.split())
right_tokens = set(right_norm.split())
token_score = len(left_tokens & right_tokens) / max(len(left_tokens | right_tokens), 1)
return round(max(sequence, token_score), 4)
def _token_set(label: str) -> frozenset:
return frozenset(label.lower().replace("_", " ").replace("-", " ").split())
def _tfidf_embedding_vectors(labels: List[str]) -> Optional[Dict[str, List[float]]]:
"""Build character-ngram TF-IDF vectors from entity labels (sklearn-based, no gensim needed)."""
if len(labels) < 2:
return None
try:
from sklearn.feature_extraction.text import TfidfVectorizer # type: ignore
normed = [" ".join(l.lower().replace("_", " ").replace("-", " ").split()) for l in labels]
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), min_df=1)
matrix = vec.fit_transform(normed)
return {label: matrix[i].toarray().flatten().tolist() for i, label in enumerate(labels)}
except Exception:
return None
def _cosine_sim(v1: List[float], v2: List[float]) -> float:
try:
from ...kg import SimilarityCalculator
return SimilarityCalculator().cosine_similarity(v1, v2)
except Exception:
import math
dot = sum(a * b for a, b in zip(v1, v2))
n1 = math.sqrt(sum(a * a for a in v1))
n2 = math.sqrt(sum(b * b for b in v2))
return dot / (n1 * n2) if n1 and n2 else 0.0
def _candidate_relation(source: Dict[str, Any], target: Dict[str, Any]) -> AlignmentRelation:
source_type = _classify_node_type(source.get("type", ""))
target_type = _classify_node_type(target.get("type", ""))
if source_type == "class" and target_type == "class":
return "owl:equivalentClass"
if source_type == "property" and target_type == "property":
return "owl:equivalentProperty"
if source_type == "concept" and target_type == "concept":
return "skos:exactMatch"
return "skos:closeMatch"
def _ontology_entities(nodes: List[Dict[str, Any]], ontology_uri: Optional[str] = None) -> List[Dict[str, Any]]:
result = []
for node in nodes:
if not _is_ontology_entity(node):
continue
if ontology_uri and not _node_belongs_to_ontology(node, ontology_uri):
continue
result.append(node)
return result
def _ontology_dict_from_nodes(uri: str, name: str, nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]) -> Dict[str, Any]:
classes = []
properties = []
for node in nodes:
entity_type = _classify_node_type(node.get("type", ""))
label = _node_label(node) or node.get("id", "")
item = {
"name": label,
"uri": node.get("id", ""),
"label": label,
"description": _entity_description(node) or "",
}
if entity_type == "class":
classes.append(item)
elif entity_type == "property":
domain = [
edge.get("target", "")
for edge in edges
if edge.get("source") == node.get("id") and edge.get("type") == "rdfs:domain"
]
range_ = [
edge.get("target", "")
for edge in edges
if edge.get("source") == node.get("id") and edge.get("type") == "rdfs:range"
]
item.update({
"type": "object" if "ObjectProperty" in node.get("type", "") else "datatype",
"domain": domain,
"range": range_,
"required": False,
})
properties.append(item)
return {
"name": name,
"namespace": _extract_namespace(uri) or uri.rstrip("#/") + "#",
"classes": classes,
"properties": properties,
}
def _basic_shacl_turtle(uri: str, name: str, nodes: List[Dict[str, Any]]) -> str:
namespace = _extract_namespace(uri) or uri.rstrip("#/") + "#"
lines = [
"@prefix sh: <http://www.w3.org/ns/shacl#> .",
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
f"@prefix onto: <{namespace}> .",
"",
]
class_nodes = [n for n in nodes if _classify_node_type(n.get("type", "")) == "class"]
for index, node in enumerate(class_nodes or nodes[:1], start=1):
label = (_node_label(node) or f"{name}Shape").replace(" ", "")
target = node.get("id") or uri
lines.extend([
f"onto:{label}Shape a sh:NodeShape ;",
f" sh:targetClass <{target}> ;",
" sh:severity sh:Violation .",
"",
])
return "\n".join(lines)
def _summarize_shapes(shacl_turtle: str, violations: Optional[List[ShaclViolation]] = None) -> List[ShaclShapeSummary]:
violations = violations or []
normalised = shacl_turtle.replace("\r\n", "\n").replace("\r", "\n")
blocks = [block.strip() for block in normalised.split(".\n") if "sh:NodeShape" in block]
summaries: List[ShaclShapeSummary] = []
for index, block in enumerate(blocks, start=1):
first = block.splitlines()[0].strip()
shape_id = first.split()[0] if first else f"shape-{index}"
target_class = None
constraints: List[str] = []
for token in ("sh:targetClass", "sh:path", "sh:minCount", "sh:maxCount", "sh:datatype", "sh:class"):
if token in block:
constraints.append(token.replace("sh:", ""))
if "sh:targetClass" in block:
after = block.split("sh:targetClass", 1)[1].strip()
target_class = after.split()[0].strip(" ;")
violation_count = sum(
1
for violation in violations
if violation.source_shape == shape_id or (target_class and violation.node == target_class)
)
summaries.append(ShaclShapeSummary(
id=shape_id,
target_class=target_class,
constraint_count=max(0, len(constraints) - 1),
constraints=constraints,
violation_count=violation_count,
))
return summaries
async def _registry_entries(request: Request, session: GraphSession) -> List[OntologyEntry]:
return await list_registry(request=request, q=None, status=None, format=None, session=session)
def _detect_format(content: str) -> str:
stripped = content.strip()[:500]
if stripped.startswith("{") or stripped.startswith("["):
@@ -1478,6 +1838,408 @@ async def get_skos_concept(
)
# alignments, health, and SHACL studio
@router.get("/alignments", response_model=List[OntologyAlignment])
async def list_alignments(
request: Request,
uri: Optional[str] = Query(None),
):
store = _get_alignment_store(request)
alignments = list(store.values())
if uri:
alignments = [
item for item in alignments
if item.source_uri.startswith(uri) or item.target_uri.startswith(uri)
]
return sorted(alignments, key=lambda item: (item.source_label, item.target_label, item.relation))
@router.post("/alignments", response_model=OntologyAlignment)
async def upsert_alignment(
request: Request,
body: OntologyAlignmentRequest,
session: GraphSession = Depends(get_session),
):
source_node = await asyncio.to_thread(session.get_node, body.source_uri)
target_node = await asyncio.to_thread(session.get_node, body.target_uri)
# External vocabulary URIs (e.g. schema.org, DBpedia) are not in the local
# graph; fall back to a label derived from the URI or the caller-supplied label.
source_label = (
body.source_label
or (_node_label(source_node) if source_node is not None else None)
or _label_from_uri(body.source_uri)
)
target_label = (
body.target_label
or (_node_label(target_node) if target_node is not None else None)
or _label_from_uri(body.target_uri)
)
now = datetime.now(UTC).isoformat()
store = _get_alignment_store(request)
alignment_id = _alignment_id(body.source_uri, body.relation, body.target_uri)
existing = store.get(alignment_id)
alignment = OntologyAlignment(
id=alignment_id,
source_uri=body.source_uri,
source_label=source_label,
target_uri=body.target_uri,
target_label=target_label,
relation=body.relation,
predicate_uri=_ALIGNMENT_RELATIONS[body.relation],
confidence=body.confidence,
provenance=body.provenance,
source=body.source,
reviewer=body.reviewer,
created_at=existing.created_at if existing else now,
updated_at=now,
)
store[alignment_id] = alignment
# OntologyEngine.create_alignment() requires a TripletStore (e.g. FalkorDB) which is
# not configured in the explorer deployment. Alignments are intentionally stored only
# in request.app.state (session memory). The ephemeral-storage banner in the UI
# communicates this limitation to users.
return alignment
@router.delete("/alignments")
async def delete_alignment(request: Request, id: str = Query(...)):
store = _get_alignment_store(request)
if id not in store:
raise HTTPException(status_code=404, detail="Alignment not found.")
del store[id]
return {"status": "removed", "id": id}
@router.post("/suggest-alignments", response_model=List[AlignmentSuggestion])
async def suggest_alignments(
body: AlignmentSuggestionRequest,
session: GraphSession = Depends(get_session),
):
nodes, total_count = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
if total_count > _MAX_ANALYSIS_NODES:
logger.warning(
"suggest-alignments: graph has %d nodes; analysis capped at %d. "
"Filter by source/target ontology URI for more accurate results.",
total_count, _MAX_ANALYSIS_NODES,
)
source_nodes = _ontology_entities(nodes, body.source_ontology_uri)
target_nodes = _ontology_entities(nodes, body.target_ontology_uri)
if not body.source_ontology_uri:
source_nodes = _ontology_entities(nodes)
if not body.target_ontology_uri:
target_nodes = _ontology_entities(nodes)
# Per-side entity cap to bound the O(n²) comparison loop.
if len(source_nodes) > _MAX_ENTITIES_PER_SIDE:
logger.warning("suggest-alignments: source side capped at %d entities.", _MAX_ENTITIES_PER_SIDE)
source_nodes = source_nodes[:_MAX_ENTITIES_PER_SIDE]
if len(target_nodes) > _MAX_ENTITIES_PER_SIDE:
logger.warning("suggest-alignments: target side capped at %d entities.", _MAX_ENTITIES_PER_SIDE)
target_nodes = target_nodes[:_MAX_ENTITIES_PER_SIDE]
# Build TF-IDF character-ngram embeddings for all candidate labels.
all_entities = source_nodes + target_nodes
all_labels = list({_node_label(n) for n in all_entities if _node_label(n)})
embeddings: Optional[Dict[str, List[float]]] = await asyncio.to_thread(_tfidf_embedding_vectors, all_labels)
has_embeddings = embeddings is not None
# Pre-build token sets for targets to enable O(1) prefiltering (skip zero-overlap pairs).
target_token_sets: Dict[str, frozenset] = {
n.get("id", ""): _token_set(_node_label(n)) for n in target_nodes
}
suggestions: List[AlignmentSuggestion] = []
for source_node in source_nodes:
source_id = source_node.get("id", "")
source_label = _node_label(source_node)
source_ontology = _node_source_ontology(source_node)
source_tokens = _token_set(source_label)
source_vec = embeddings.get(source_label) if has_embeddings else None
for target_node in target_nodes:
target_id = target_node.get("id", "")
if not source_id or source_id == target_id:
continue
target_ontology = _node_source_ontology(target_node)
if source_ontology and target_ontology and source_ontology == target_ontology:
continue
# Token-overlap prefilter: skip pairs with zero shared tokens (Jaccard=0 ⇒ label_sim≈0).
target_tokens = target_token_sets.get(target_id, frozenset())
if source_tokens and target_tokens and not (source_tokens & target_tokens):
continue
target_label = _node_label(target_node)
label_score = _label_similarity(source_label, target_label)
embedding_sim: Optional[float] = None
if has_embeddings and source_vec is not None:
target_vec = embeddings.get(target_label)
if target_vec is not None:
try:
embedding_sim = round(_cosine_sim(source_vec, target_vec), 4)
except Exception:
embedding_sim = None
# Combined score: average label and embedding similarity when both are available.
if embedding_sim is not None:
score = round(0.4 * label_score + 0.6 * embedding_sim, 4)
reason = (
f"Label similarity {label_score:.2f}, embedding cosine similarity {embedding_sim:.2f} "
f"(TF-IDF character n-gram vectors via SimilarityCalculator)."
)
else:
score = label_score
reason = f"Label similarity {label_score:.2f}; embedding vectors unavailable."
if score < body.threshold:
continue
relation = _candidate_relation(source_node, target_node)
suggestions.append(AlignmentSuggestion(
source_uri=source_id,
source_label=source_label,
target_uri=target_id,
target_label=target_label,
relation=relation,
score=score,
label_similarity=label_score,
embedding_similarity=embedding_sim,
reason=reason,
))
suggestions.sort(key=lambda item: item.score, reverse=True)
return suggestions[:body.limit]
@router.get("/health", response_model=OntologyHealthResponse)
async def ontology_health(
request: Request,
uri: str = Query(...),
session: GraphSession = Depends(get_session),
):
registry = {entry.uri: entry for entry in await _registry_entries(request, session)}
entry = registry.get(uri)
if entry is None:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
nodes, total_nodes = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES)
if total_nodes > _MAX_ANALYSIS_NODES:
logger.warning("ontology-health: graph has %d nodes; analysis capped at %d.", total_nodes, _MAX_ANALYSIS_NODES)
entities = _ontology_entities(nodes, uri)
classes = [node for node in entities if _classify_node_type(node.get("type", "")) == "class"]
properties = [node for node in entities if _classify_node_type(node.get("type", "")) == "property"]
assessed = classes + properties
issues: List[HealthIssue] = []
total = max(len(assessed), 1)
with_label = sum(1 for node in assessed if _node_label(node))
with_comment = sum(1 for node in assessed if _entity_description(node))
with_definition = sum(1 for node in assessed if _entity_definition(node))
completeness_score = ((with_label + with_comment + with_definition) / (total * 3)) * 100
for node in assessed:
label = _node_label(node)
if not _entity_description(node):
issues.append(HealthIssue(
id=f"doc:{node.get('id')}",
severity="warning",
category="Documentation",
entity_uri=node.get("id"),
entity_label=label,
message=f"{label} is missing a comment or definition.",
action="Add documentation in Editor.",
))
property_range_edges = {
edge.get("source")
for edge in edges
if edge.get("type") == "rdfs:range"
}
missing_range = [node for node in properties if node.get("id") not in property_range_edges]
for node in missing_range[:25]:
issues.append(HealthIssue(
id=f"range:{node.get('id')}",
severity="info",
category="Consistency",
entity_uri=node.get("id"),
entity_label=_node_label(node),
message="Property has no explicit rdfs:range.",
action="Review property range in Editor.",
))
consistency_score = max(0.0, 100.0 - (len(missing_range) / max(len(properties), 1)) * 60.0)
assessed_ids = {node.get("id") for node in assessed if node.get("id")}
alignments = _get_alignment_store(request).values()
aligned_sources = {
item.source_uri for item in alignments if item.source_uri in assessed_ids
} | {
item.target_uri for item in alignments if item.target_uri in assessed_ids
}
alignment_score = (len(aligned_sources) / total) * 100
if assessed and not aligned_sources:
issues.append(HealthIssue(
id=f"alignment:{uri}",
severity="warning",
category="Alignment",
message="No cross-ontology alignments are recorded for local classes or properties.",
action="Review suggested alignments.",
))
documentation_score = ((with_comment / total) * 80.0) + (20.0 if entry.version or entry.source_url else 0.0)
shacl_dimension = HealthDimension(
key="shacl",
label="SHACL Conformance",
score=0.0,
status="unavailable",
detail="Live SHACL validation is available in SHACL Studio when optional validation dependencies are installed.",
)
dimensions = [
HealthDimension(
key="completeness",
label="Completeness",
score=round(completeness_score, 1),
status="ok" if completeness_score >= 80 else "warning",
detail=f"{with_label}/{total} labeled, {with_comment}/{total} documented, {with_definition}/{total} defined.",
),
HealthDimension(
key="consistency",
label="Consistency",
score=round(consistency_score, 1),
status="ok" if consistency_score >= 80 else "warning",
detail=f"{len(missing_range)} properties are missing explicit ranges.",
),
shacl_dimension,
HealthDimension(
key="alignment",
label="Alignment Coverage",
score=round(alignment_score, 1),
status="ok" if alignment_score >= 50 else "warning",
detail=f"{len(aligned_sources)}/{total} classes or properties have an alignment.",
),
HealthDimension(
key="documentation",
label="Documentation",
score=round(documentation_score, 1),
status="ok" if documentation_score >= 75 else "warning",
detail="Measures comments plus source/version metadata.",
),
]
scoreable = [dim for dim in dimensions if dim.status != "unavailable"]
total_score = sum(dim.score for dim in scoreable) / max(len(scoreable), 1)
return OntologyHealthResponse(
uri=uri,
name=entry.name,
total_score=round(total_score, 1),
dimensions=dimensions,
issues=issues[:100],
generated_at=datetime.now(UTC).isoformat(),
)
async def _generated_shacl_for_uri(
request: Request,
session: GraphSession,
uri: str,
quality_tier: str = "strict",
) -> tuple[str, List[ShaclShapeSummary]]:
registry = {entry.uri: entry for entry in await _registry_entries(request, session)}
entry = registry.get(uri)
if entry is None:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES)
entities = _ontology_entities(nodes, uri)
ontology_dict = _ontology_dict_from_nodes(uri, entry.name, entities, edges)
try:
from ...ontology import OntologyEngine
engine = OntologyEngine()
shacl_turtle = await asyncio.to_thread(
engine.to_shacl,
ontology_dict,
format="turtle",
quality_tier=quality_tier,
validate_output=False,
)
except Exception as exc:
logger.debug("OntologyEngine.to_shacl unavailable; using basic generator: %s", exc)
shacl_turtle = _basic_shacl_turtle(uri, entry.name, entities)
return shacl_turtle, _summarize_shapes(shacl_turtle)
@router.post("/shacl/generate", response_model=ShaclGenerateResponse)
async def generate_shacl(
request: Request,
body: ShaclGenerateRequest,
session: GraphSession = Depends(get_session),
):
shacl_turtle, shapes = await _generated_shacl_for_uri(request, session, body.uri, body.quality_tier)
return ShaclGenerateResponse(
uri=body.uri,
shacl_turtle=shacl_turtle,
shape_count=len(shapes),
generated_at=datetime.now(UTC).isoformat(),
)
@router.get("/shacl/shapes", response_model=ShaclShapesResponse)
async def list_shacl_shapes(
request: Request,
uri: str = Query(...),
session: GraphSession = Depends(get_session),
):
shacl_turtle, shapes = await _generated_shacl_for_uri(request, session, uri)
return ShaclShapesResponse(
uri=uri,
shapes=shapes,
shacl_turtle=shacl_turtle,
generated_at=datetime.now(UTC).isoformat(),
)
@router.post("/shacl/validate", response_model=ShaclValidationResponse)
async def validate_shacl(body: ShaclValidateRequest):
if not body.shacl_turtle.strip():
raise HTTPException(status_code=422, detail="SHACL Turtle cannot be empty.")
# Syntax-check the submitted Turtle with rdflib before claiming anything about it.
try:
import rdflib # type: ignore
g = rdflib.Graph()
await asyncio.to_thread(g.parse, data=body.shacl_turtle, format="turtle")
except ImportError:
pass # rdflib unavailable; skip syntax check
except Exception as exc:
raise HTTPException(
status_code=422,
detail=f"Invalid Turtle syntax: {exc}",
) from exc
# Live data-graph validation requires pySHACL wired to OntologyEngine.validate_graph().
return ShaclValidationResponse(
uri=body.uri,
conforms=False,
status="unavailable",
message=(
"Turtle parsed successfully. "
"Live graph validation is not yet wired to a data graph — "
"install semantica[shacl] and connect OntologyEngine.validate_graph() to enable full validation."
),
violations=[],
)
# ---------------------------------------------------------------------------
# Wildcard management endpoints (must come after specific routes)
# ---------------------------------------------------------------------------
+263
View File
@@ -0,0 +1,263 @@
"""Tests for Ontology Hub subissue 3 APIs."""
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
def _build_ontology_graph() -> ContextGraph:
graph = ContextGraph(advanced_analytics=False)
onto_a = "http://example.org/onto-a"
onto_b = "http://example.org/onto-b"
person_a = "http://example.org/onto-a#Person"
person_b = "http://example.org/onto-b#PersonRecord"
name_a = "http://example.org/onto-a#name"
graph.add_node(
onto_a,
node_type="owl:Ontology",
content="Ontology A",
**{"rdfs:label": "Ontology A", "rdfs:comment": "Primary ontology", "version": "1.0.0"},
)
graph.add_node(
onto_b,
node_type="owl:Ontology",
content="Ontology B",
**{"rdfs:label": "Ontology B", "rdfs:comment": "Partner ontology", "version": "1.0.0"},
)
graph.add_node(
person_a,
node_type="owl:Class",
content="Person",
scheme_uri=onto_a,
**{"rdfs:label": "Person", "rdfs:comment": "A person", "skos:definition": "Human actor"},
)
graph.add_node(
name_a,
node_type="owl:DatatypeProperty",
content="name",
scheme_uri=onto_a,
**{"rdfs:label": "name", "rdfs:comment": "Display name"},
)
graph.add_node(
person_b,
node_type="owl:Class",
content="Person Record",
scheme_uri=onto_b,
**{"rdfs:label": "Person Record", "rdfs:comment": "A person profile"},
)
graph.add_edge(name_a, person_a, edge_type="rdfs:domain")
return graph
@pytest.fixture()
def client():
app = create_app(session=GraphSession(_build_ontology_graph()))
with TestClient(app) as test_client:
yield test_client
def test_alignment_round_trip(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.91,
"provenance": "Reviewed from source mapping table",
"source": "test",
"reviewer": "qa",
}
created = client.post("/api/ontology/alignments", json=payload)
assert created.status_code == 200
alignment = created.json()
assert alignment["confidence"] == 0.91
assert alignment["provenance"] == "Reviewed from source mapping table"
listed = client.get("/api/ontology/alignments")
assert listed.status_code == 200
assert [item["id"] for item in listed.json()] == [alignment["id"]]
removed = client.delete(f"/api/ontology/alignments?id={alignment['id']}")
assert removed.status_code == 200
assert client.get("/api/ontology/alignments").json() == []
def test_alignment_suggestions_are_ranked(client):
response = client.post(
"/api/ontology/suggest-alignments",
json={
"source_ontology_uri": "http://example.org/onto-a",
"target_ontology_uri": "http://example.org/onto-b",
"threshold": 0.35,
"limit": 5,
},
)
assert response.status_code == 200
suggestions = response.json()
assert suggestions
# Top suggestion should be the Person→PersonRecord pair (highest label similarity).
top = suggestions[0]
assert "Person" in top["source_label"]
assert "Person" in top["target_label"]
# Results must be sorted descending by score.
assert suggestions == sorted(suggestions, key=lambda item: item["score"], reverse=True)
def test_health_returns_dimensions_and_issues(client):
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a")
assert response.status_code == 200
payload = response.json()
assert payload["total_score"] >= 0
assert {dimension["key"] for dimension in payload["dimensions"]} == {
"completeness",
"consistency",
"shacl",
"alignment",
"documentation",
}
assert isinstance(payload["issues"], list)
def test_shacl_generate_and_shapes(client):
response = client.post(
"/api/ontology/shacl/generate",
json={"uri": "http://example.org/onto-a", "quality_tier": "strict"},
)
assert response.status_code == 200
payload = response.json()
assert "sh:NodeShape" in payload["shacl_turtle"]
assert payload["shape_count"] >= 1
shapes = client.get("/api/ontology/shacl/shapes?uri=http%3A%2F%2Fexample.org%2Fonto-a")
assert shapes.status_code == 200
assert shapes.json()["shapes"]
def test_shacl_validate_returns_unavailable(client):
response = client.post(
"/api/ontology/shacl/validate",
json={
"uri": "http://example.org/onto-a",
"shacl_turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> .",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "unavailable", "stub must not report conforms=True before validation is wired"
assert payload["conforms"] is False
assert isinstance(payload["violations"], list)
def test_shacl_validate_rejects_empty_turtle(client):
response = client.post(
"/api/ontology/shacl/validate",
json={"uri": "http://example.org/onto-a", "shacl_turtle": " "},
)
assert response.status_code == 422
def test_health_returns_404_for_unknown_ontology(client):
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fnot-loaded.example%2Fonto")
assert response.status_code == 404
def test_health_shacl_dimension_is_zero_when_unavailable(client):
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
shacl_dim = next(d for d in payload["dimensions"] if d["key"] == "shacl")
assert shacl_dim["status"] == "unavailable"
assert shacl_dim["score"] == 0.0
# Total score must NOT include the unavailable dimension in its average.
scoreable = [d for d in payload["dimensions"] if d["status"] != "unavailable"]
expected_total = round(sum(d["score"] for d in scoreable) / len(scoreable), 1)
assert payload["total_score"] == expected_total
def test_delete_unknown_alignment_returns_404(client):
response = client.delete("/api/ontology/alignments?id=does-not-exist")
assert response.status_code == 404
def test_alignment_upsert_is_idempotent(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.80,
}
first = client.post("/api/ontology/alignments", json=payload).json()
updated_payload = {**payload, "confidence": 0.95}
second = client.post("/api/ontology/alignments", json=updated_payload).json()
assert first["id"] == second["id"], "upsert must reuse the same deterministic ID"
assert second["confidence"] == 0.95
assert second["created_at"] == first["created_at"], "created_at must not change on update"
listed = client.get("/api/ontology/alignments").json()
assert len(listed) == 1
def test_alignment_accepts_external_uri(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://schema.org/Person", # not in local graph
"relation": "owl:equivalentClass",
"confidence": 0.75,
}
response = client.post("/api/ontology/alignments", json=payload)
assert response.status_code == 200
alignment = response.json()
assert alignment["target_label"] == "Person" # derived from URI fragment
def test_suggest_alignments_returns_embedding_similarity(client):
response = client.post(
"/api/ontology/suggest-alignments",
json={
"source_ontology_uri": "http://example.org/onto-a",
"target_ontology_uri": "http://example.org/onto-b",
"threshold": 0.20,
"limit": 10,
},
)
assert response.status_code == 200
suggestions = response.json()
assert suggestions
# When sklearn is available, embedding_similarity should be populated.
top = suggestions[0]
assert top["embedding_similarity"] is not None, (
"TF-IDF embedding similarity must be returned when sklearn is installed"
)
# Combined score must be a weighted blend, not purely the label score.
assert top["score"] != top["label_similarity"] or top["embedding_similarity"] == top["label_similarity"]
def test_shacl_validate_rejects_invalid_turtle_syntax(client):
response = client.post(
"/api/ontology/shacl/validate",
json={
"uri": "http://example.org/onto-a",
"shacl_turtle": "this is not valid turtle !!!",
},
)
assert response.status_code == 422
def test_health_alignment_coverage_uses_set_lookup(client):
# Create an alignment first so coverage score can be non-zero.
client.post("/api/ontology/alignments", json={
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.9,
})
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
alignment_dim = next(d for d in payload["dimensions"] if d["key"] == "alignment")
assert alignment_dim["score"] > 0.0, "alignment coverage must be non-zero after recording an alignment"