fix(ontology): address review blockers from PR #524

Backend:
- Replace false conforms=True SHACL stub with status=unavailable always;
  live validation cannot be wired until OntologyEngine.validate_graph is
  connected to a data graph — a stub that returns conforms=True misleads
  users editing shapes
- Cap node/edge fetches in health, suggest-alignments, and SHACL generation
  at _MAX_ANALYSIS_NODES (5 000) with a logger.warning when the graph
  exceeds the limit; unbounded limit=999_999 fetches cause OOM on large graphs
- Set SHACL health dimension score to 0.0 (was 70.0) when status=unavailable;
  exclude unavailable dimensions from the total_score average so they neither
  inflate nor deflate the result
- Allow alignments to reference external/unloaded URIs (e.g. schema.org)
  without raising 404; label falls back to URI fragment or caller-supplied
  source_label/target_label fields added to OntologyAlignmentRequest
- Fix _alignment_id to use uuid.NAMESPACE_OID instead of NAMESPACE_URL;
  the composite key is not a URL
- Fix _summarize_shapes to normalise \r\n before splitting on .\n so shape
  parsing works correctly on Windows line endings

Frontend:
- Wrap handleSave/handleSuggest/handleRemove/handleAcceptSuggestion in
  useCallback in AlignmentsTab for consistency with sibling components
- Add ephemeral-storage banner in AlignmentsTab warning that alignments are
  session-memory-only and not persisted across restarts
- Fix exportReport in HealthTab to append/remove anchor from document before
  clicking and defer URL.revokeObjectURL to avoid Blob URL leak in some browsers
- Derive health dimension grid column count from health.dimensions.length
  instead of the hardcoded repeat(5, ...) that breaks if the backend adds
  or removes a dimension
- Add minimal Monarch tokenizer for the Monaco turtle language registration
  in ShaclStudio so prefix declarations, IRIs, SHACL properties, comments,
  and string literals are syntax-highlighted; previously the editor rendered
  as plain text despite theme rules being defined

Tests (11 passing, was 5):
- Rename test_shacl_validate_has_stable_contract to
  test_shacl_validate_returns_unavailable and assert status == unavailable
- Add test_shacl_validate_rejects_empty_turtle (expects 422)
- Add test_health_returns_404_for_unknown_ontology
- Add test_health_shacl_dimension_is_zero_when_unavailable with total_score check
- Add test_delete_unknown_alignment_returns_404
- Add test_alignment_upsert_is_idempotent (verifies ID stability and created_at
  preservation across updates)
- Add test_alignment_accepts_external_uri (verifies no 404 for schema.org URIs)
- Relax test_alignment_suggestions_are_ranked label assertions to substring
  checks so the test survives similarity algorithm changes

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