mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
3401 lines
123 KiB
Python
3401 lines
123 KiB
Python
"""
|
|
Ontology Hub routes: registry, URL/file loading, preview, creation, entity search, and SKOS.
|
|
"""
|
|
|
|
import os
|
|
import asyncio
|
|
import ipaddress
|
|
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
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..session import GraphSession
|
|
from ..dependencies import get_session
|
|
from ..utils.rdf_parser import _safe_parse_rdf
|
|
try:
|
|
from rdflib.namespace import DCT, DC
|
|
except ImportError:
|
|
# Fallback if DCT/DC not available
|
|
DCT = None
|
|
DC = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Create router
|
|
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 GraphTruncationError(Exception):
|
|
"""Raised when a graph exceeds _MAX_ANALYSIS_NODES so analysis would be truncated."""
|
|
pass
|
|
|
|
|
|
_CLASS_TYPES = frozenset({
|
|
"owl:Class", "rdfs:Class",
|
|
"http://www.w3.org/2002/07/owl#Class",
|
|
"http://www.w3.org/2000/01/rdf-schema#Class",
|
|
})
|
|
_PROPERTY_TYPES = frozenset({
|
|
"owl:ObjectProperty", "owl:DatatypeProperty", "owl:AnnotationProperty",
|
|
"rdfs:Property",
|
|
"http://www.w3.org/2002/07/owl#ObjectProperty",
|
|
"http://www.w3.org/2002/07/owl#DatatypeProperty",
|
|
"http://www.w3.org/2002/07/owl#AnnotationProperty",
|
|
})
|
|
_INDIVIDUAL_TYPES = frozenset({
|
|
"owl:NamedIndividual",
|
|
"http://www.w3.org/2002/07/owl#NamedIndividual",
|
|
})
|
|
_CONCEPT_TYPES = frozenset({
|
|
"skos:Concept",
|
|
"http://www.w3.org/2004/02/skos/core#Concept",
|
|
})
|
|
_SCHEME_TYPES = frozenset({
|
|
"skos:ConceptScheme",
|
|
"http://www.w3.org/2004/02/skos/core#ConceptScheme",
|
|
})
|
|
_ONTOLOGY_TYPES = frozenset({
|
|
"owl:Ontology",
|
|
"http://www.w3.org/2002/07/owl#Ontology",
|
|
}) | _SCHEME_TYPES
|
|
|
|
_SEARCHABLE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _INDIVIDUAL_TYPES | _CONCEPT_TYPES | _SCHEME_TYPES
|
|
|
|
_URI_PREFIX_MAP = {
|
|
"http://www.w3.org/2002/07/owl#": "owl:",
|
|
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
|
|
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
|
|
"http://www.w3.org/2004/02/skos/core#": "skos:",
|
|
"http://purl.org/dc/terms/": "dcterms:",
|
|
"http://purl.org/dc/elements/1.1/": "dc:",
|
|
"http://schema.org/": "schema:",
|
|
"http://www.w3.org/ns/shacl#": "sh:",
|
|
}
|
|
|
|
_FORMAT_ALIASES: Dict[str, str] = {
|
|
"ttl": "turtle",
|
|
"rdf": "xml",
|
|
"owl": "xml",
|
|
"jsonld": "json-ld",
|
|
"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",
|
|
"json-ld": ".jsonld",
|
|
"nt": ".nt",
|
|
"n3": ".n3",
|
|
"trig": ".trig",
|
|
"nquads": ".nq",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SHACL Validation Resource Guardrails
|
|
# ---------------------------------------------------------------------------
|
|
_MAX_SHACL_TURTLE_BYTES: int = int(
|
|
os.environ.get("SEMANTICA_MAX_SHACL_TURTLE_BYTES", "262144")
|
|
) # 256 KB
|
|
_MAX_SHACL_TRIPLES: int = int(
|
|
os.environ.get("SEMANTICA_MAX_SHACL_TRIPLES", "1000")
|
|
) # 1,000 triples
|
|
_MAX_SHACL_TIMEOUT_SECONDS: float = float(
|
|
os.environ.get("SEMANTICA_MAX_SHACL_TIMEOUT", "15.0")
|
|
)
|
|
_MAX_SHACL_CONCURRENCY: int = int(
|
|
os.environ.get("SEMANTICA_MAX_SHACL_CONCURRENCY", "4")
|
|
)
|
|
_shacl_validation_semaphore: Optional[
|
|
Tuple[asyncio.AbstractEventLoop, asyncio.Semaphore]
|
|
] = None
|
|
|
|
|
|
def _get_shacl_semaphore() -> asyncio.Semaphore:
|
|
global _shacl_validation_semaphore
|
|
try:
|
|
current_loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
current_loop = None
|
|
if (
|
|
_shacl_validation_semaphore is None
|
|
or _shacl_validation_semaphore[0] != current_loop
|
|
):
|
|
_shacl_validation_semaphore = (
|
|
current_loop,
|
|
asyncio.Semaphore(_MAX_SHACL_CONCURRENCY),
|
|
)
|
|
return _shacl_validation_semaphore[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class OntologyEntry(BaseModel):
|
|
uri: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
format: str = "unknown"
|
|
status: Literal["published", "draft", "external"] = "external"
|
|
source_url: Optional[str] = None
|
|
version: Optional[str] = None
|
|
class_count: int = 0
|
|
concept_count: int = 0
|
|
property_count: int = 0
|
|
loaded_at: str = ""
|
|
enabled: bool = True
|
|
tags: List[str] = Field(default_factory=list)
|
|
|
|
|
|
class OntologyPreview(BaseModel):
|
|
uri: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
namespace: Optional[str] = None
|
|
version: Optional[str] = None
|
|
license: Optional[str] = None
|
|
format: str
|
|
estimated_triples: int = 0
|
|
source_url: Optional[str] = None
|
|
|
|
|
|
class LoadOntologyRequest(BaseModel):
|
|
url: Optional[str] = None
|
|
content: Optional[str] = None
|
|
format: Optional[str] = None
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
tags: List[str] = Field(default_factory=list)
|
|
|
|
|
|
class PreviewOntologyRequest(BaseModel):
|
|
url: Optional[str] = None
|
|
content: Optional[str] = None
|
|
format: Optional[str] = None
|
|
|
|
|
|
class CreateOntologyRequest(BaseModel):
|
|
mode: Literal["scratch", "data", "text"] = "scratch"
|
|
namespace: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
tags: List[str] = Field(default_factory=list)
|
|
sample_data: Optional[str] = None
|
|
schema_text: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
model: Optional[str] = None
|
|
|
|
|
|
class OntologySearchResult(BaseModel):
|
|
uri: str
|
|
label: str
|
|
type: str
|
|
entity_type: str
|
|
definition: Optional[str] = None
|
|
source_ontology: Optional[str] = None
|
|
namespace_prefix: Optional[str] = None
|
|
|
|
|
|
class EntityDetailResponse(BaseModel):
|
|
uri: str
|
|
label: str
|
|
type: str
|
|
entity_type: str
|
|
definition: Optional[str] = None
|
|
source_ontology: Optional[str] = None
|
|
superclasses: List[str] = Field(default_factory=list)
|
|
subclasses: List[str] = Field(default_factory=list)
|
|
domain: List[str] = Field(default_factory=list)
|
|
range: List[str] = Field(default_factory=list)
|
|
instance_count: int = 0
|
|
properties: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class SKOSScheme(BaseModel):
|
|
uri: str
|
|
title: str
|
|
description: Optional[str] = None
|
|
concept_count: int = 0
|
|
|
|
|
|
class SKOSConceptDetail(BaseModel):
|
|
uri: str
|
|
pref_label: str
|
|
alt_labels: List[str] = Field(default_factory=list)
|
|
hidden_labels: List[str] = Field(default_factory=list)
|
|
definition: Optional[str] = None
|
|
scope_note: Optional[str] = None
|
|
editorial_note: Optional[str] = None
|
|
broader: List[str] = Field(default_factory=list)
|
|
narrower: List[str] = Field(default_factory=list)
|
|
related: List[str] = Field(default_factory=list)
|
|
exact_match: List[str] = Field(default_factory=list)
|
|
close_match: List[str] = Field(default_factory=list)
|
|
broad_match: List[str] = Field(default_factory=list)
|
|
narrow_match: List[str] = Field(default_factory=list)
|
|
scheme_uri: Optional[str] = None
|
|
|
|
|
|
class SKOSConceptSearchRequest(BaseModel):
|
|
query: str
|
|
scheme_uri: Optional[str] = None
|
|
|
|
|
|
class LoadOntologyResponse(BaseModel):
|
|
status: str = "success"
|
|
uri: str
|
|
name: str
|
|
nodes_added: int = 0
|
|
edges_added: int = 0
|
|
format: str = "unknown"
|
|
|
|
|
|
class ToggleResponse(BaseModel):
|
|
uri: str
|
|
enabled: bool
|
|
|
|
|
|
class RefreshResponse(BaseModel):
|
|
status: str = "success"
|
|
uri: str
|
|
nodes_added: int = 0
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class DraftDiff(BaseModel):
|
|
added_classes: List[str] = Field(default_factory=list)
|
|
removed_classes: List[str] = Field(default_factory=list)
|
|
modified_classes: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
|
added_properties: List[str] = Field(default_factory=list)
|
|
removed_properties: List[str] = Field(default_factory=list)
|
|
modified_properties: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
|
added_restrictions: List[Dict[str, Any]] = Field(default_factory=list)
|
|
removed_restrictions: List[Dict[str, Any]] = Field(default_factory=list)
|
|
added_axioms: List[Dict[str, Any]] = Field(default_factory=list)
|
|
removed_axioms: List[Dict[str, Any]] = Field(default_factory=list)
|
|
annotation_changes: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
|
|
|
|
|
class DraftRequest(BaseModel):
|
|
ontology_uri: str
|
|
diff: DraftDiff
|
|
author: str
|
|
summary: Optional[str] = None
|
|
timestamp: Optional[str] = None
|
|
|
|
|
|
class DraftResponse(BaseModel):
|
|
draft_id: str
|
|
ontology_uri: str
|
|
diff: DraftDiff
|
|
author: str
|
|
summary: Optional[str] = None
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
class ProposalState(BaseModel):
|
|
state: Literal["draft", "proposed", "approved", "published", "rejected"]
|
|
|
|
|
|
class ProposalRequest(BaseModel):
|
|
draft_id: str
|
|
ontology_uri: str
|
|
summary: str
|
|
reviewer: Optional[str] = None
|
|
|
|
|
|
class ProposalResponse(BaseModel):
|
|
proposal_id: str
|
|
draft_id: str
|
|
ontology_uri: str
|
|
summary: str
|
|
author: str
|
|
reviewer: Optional[str] = None
|
|
state: Literal["draft", "proposed", "approved", "published", "rejected"]
|
|
impact_analysis: Dict[str, Any] = Field(default_factory=dict)
|
|
shacl_validation: Dict[str, Any] = Field(default_factory=dict)
|
|
created_at: str
|
|
updated_at: str
|
|
comments: List[Dict[str, Any]] = Field(default_factory=list)
|
|
|
|
|
|
class CommentRequest(BaseModel):
|
|
element_uri: str
|
|
text: str
|
|
author: str
|
|
|
|
|
|
class VersionEntry(BaseModel):
|
|
version_id: str
|
|
ontology_uri: str
|
|
state: Literal["draft", "published"]
|
|
author: str
|
|
date: str
|
|
diff_summary: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class VersionCompareRequest(BaseModel):
|
|
version1: str
|
|
version2: str
|
|
|
|
|
|
class VersionCompareResponse(BaseModel):
|
|
version1: str
|
|
version2: str
|
|
metadata_changes: Dict[str, Any] = Field(default_factory=dict)
|
|
class_changes: Dict[str, Any] = Field(default_factory=dict)
|
|
property_changes: Dict[str, Any] = Field(default_factory=dict)
|
|
restriction_changes: Dict[str, Any] = Field(default_factory=dict)
|
|
axiom_changes: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class AlignmentRequest(BaseModel):
|
|
source_uri: str
|
|
target_uri: str
|
|
predicate: str
|
|
|
|
|
|
class AlignmentResponse(BaseModel):
|
|
source: str
|
|
predicate: str
|
|
target: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _get_registry(request: Request) -> Dict[str, OntologyEntry]:
|
|
if not hasattr(request.app.state, "ontology_registry"):
|
|
request.app.state.ontology_registry = {}
|
|
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 = {}
|
|
return request.app.state.ontology_drafts
|
|
|
|
|
|
def _get_proposals(request: Request) -> Dict[str, ProposalResponse]:
|
|
if not hasattr(request.app.state, "ontology_proposals"):
|
|
request.app.state.ontology_proposals = {}
|
|
return request.app.state.ontology_proposals
|
|
|
|
|
|
def _get_versions(request: Request) -> Dict[str, List[VersionEntry]]:
|
|
if not hasattr(request.app.state, "ontology_versions"):
|
|
request.app.state.ontology_versions = {}
|
|
return request.app.state.ontology_versions
|
|
|
|
|
|
def _alignment_key(source: str, predicate: str, target: str) -> str:
|
|
return f"{source}::{predicate}::{target}"
|
|
|
|
|
|
def _coerce_alignment(raw: Any) -> Optional[AlignmentResponse]:
|
|
if isinstance(raw, AlignmentResponse):
|
|
return raw
|
|
if isinstance(raw, dict):
|
|
source = raw.get("source") or raw.get("source_uri")
|
|
target = raw.get("target") or raw.get("target_uri")
|
|
predicate = raw.get("predicate") or raw.get("relation")
|
|
if source and target and predicate:
|
|
return AlignmentResponse(source=source, predicate=predicate, target=target)
|
|
return None
|
|
|
|
|
|
def _version_field(version: Any, field: str, default: Any = None) -> Any:
|
|
if isinstance(version, VersionEntry):
|
|
return getattr(version, field)
|
|
if isinstance(version, dict):
|
|
return version.get(field, default)
|
|
return default
|
|
|
|
|
|
def _uri_to_prefix(uri: str) -> str:
|
|
for base, prefix in _URI_PREFIX_MAP.items():
|
|
if uri.startswith(base):
|
|
return prefix + uri[len(base):]
|
|
return uri
|
|
|
|
|
|
def _classify_node_type(node_type: str) -> str:
|
|
if node_type in _CLASS_TYPES:
|
|
return "class"
|
|
if node_type in _PROPERTY_TYPES:
|
|
return "property"
|
|
if node_type in _INDIVIDUAL_TYPES:
|
|
return "individual"
|
|
if node_type in _CONCEPT_TYPES:
|
|
return "concept"
|
|
if node_type in _SCHEME_TYPES:
|
|
return "scheme"
|
|
if node_type in _ONTOLOGY_TYPES:
|
|
return "ontology"
|
|
return "unknown"
|
|
|
|
|
|
def _node_label(node: Dict[str, Any]) -> str:
|
|
props = node.get("properties", {})
|
|
return (
|
|
props.get("pref_label")
|
|
or props.get("rdfs:label")
|
|
or props.get("skos:prefLabel")
|
|
or props.get("label")
|
|
or props.get("content")
|
|
or node.get("content", "")
|
|
or node.get("id", "")
|
|
)
|
|
|
|
|
|
def _as_uri_list(value: Any) -> List[str]:
|
|
if value is None or value == "":
|
|
return []
|
|
if isinstance(value, list):
|
|
result = []
|
|
for item in value:
|
|
if item is None or item == "":
|
|
continue
|
|
if isinstance(item, dict):
|
|
uri = item.get("uri") or item.get("id") or item.get("@id")
|
|
if uri:
|
|
result.append(str(uri))
|
|
else:
|
|
result.append(str(item))
|
|
return result
|
|
if isinstance(value, dict):
|
|
uri = value.get("uri") or value.get("id") or value.get("@id")
|
|
return [str(uri)] if uri else []
|
|
return [str(value)]
|
|
|
|
|
|
def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Convert OntologyIngestor ontology dict to graph nodes and edges."""
|
|
nodes = []
|
|
edges = []
|
|
|
|
# Add ontology node
|
|
ontology_uri = ontology_dict.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
|
|
nodes.append({
|
|
"id": ontology_uri,
|
|
"type": "owl:Ontology",
|
|
"content": ontology_dict.get("name", "Ontology"),
|
|
"properties": {
|
|
"rdfs:label": ontology_dict.get("name", "Ontology"),
|
|
"rdfs:comment": ontology_dict.get("description", ""),
|
|
"uri": ontology_uri,
|
|
},
|
|
})
|
|
|
|
# Add class nodes
|
|
for cls in ontology_dict.get("classes", []):
|
|
cls_uri = cls.get("uri", f"temp:class:{uuid.uuid4().hex[:12]}")
|
|
node = {
|
|
"id": cls_uri,
|
|
"type": "owl:Class",
|
|
"content": cls.get("name", cls.get("label", "")),
|
|
"properties": {
|
|
"rdfs:label": cls.get("label", cls.get("name", "")),
|
|
"rdfs:comment": cls.get("description", ""),
|
|
"uri": cls_uri,
|
|
},
|
|
}
|
|
nodes.append(node)
|
|
|
|
# Add subclass edges
|
|
for parent in cls.get("parents", []):
|
|
edges.append({
|
|
"source": cls_uri,
|
|
"target": parent,
|
|
"type": "rdfs:subClassOf",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
# Add property nodes and edges
|
|
for prop in ontology_dict.get("properties", []):
|
|
prop_uri = prop.get("uri", f"temp:prop:{uuid.uuid4().hex[:12]}")
|
|
node = {
|
|
"id": prop_uri,
|
|
"type": f"owl:{prop.get('type', 'Object').title()}Property",
|
|
"content": prop.get("name", prop.get("label", "")),
|
|
"properties": {
|
|
"rdfs:label": prop.get("label", prop.get("name", "")),
|
|
"rdfs:comment": prop.get("description", ""),
|
|
"uri": prop_uri,
|
|
},
|
|
}
|
|
nodes.append(node)
|
|
|
|
# Add domain and range edges
|
|
for domain_uri in _as_uri_list(prop.get("domain")):
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": domain_uri,
|
|
"type": "rdfs:domain",
|
|
"weight": 1.0,
|
|
})
|
|
for range_uri in _as_uri_list(prop.get("range")):
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": range_uri,
|
|
"type": "rdfs:range",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
return nodes, edges
|
|
|
|
|
|
def _extract_namespace(uri: str) -> Optional[str]:
|
|
if "#" in uri:
|
|
return uri.rsplit("#", 1)[0] + "#"
|
|
if "/" in uri:
|
|
return uri.rsplit("/", 1)[0] + "/"
|
|
return None
|
|
|
|
|
|
def _ontology_namespace(uri: str) -> str:
|
|
if "#" in uri:
|
|
return uri.rsplit("#", 1)[0] + "#"
|
|
if uri.endswith("/"):
|
|
return uri
|
|
return uri.rstrip("#/") + "#"
|
|
|
|
|
|
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
|
|
stem = ontology_uri.rstrip("#/")
|
|
return nid.startswith((stem + "#", stem + "/"))
|
|
|
|
|
|
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 _data_graph_entities(nodes: List[Dict[str, Any]], ontology_uri: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for node in nodes:
|
|
if _classify_node_type(node.get("type", "")) not in {"class", "property", "individual", "concept", "scheme"}:
|
|
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": _ontology_namespace(uri),
|
|
"classes": classes,
|
|
"properties": properties,
|
|
}
|
|
|
|
|
|
def _basic_shacl_turtle(uri: str, name: str, nodes: List[Dict[str, Any]]) -> str:
|
|
namespace = _ontology_namespace(uri)
|
|
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("["):
|
|
return "json-ld"
|
|
if stripped.startswith("<"):
|
|
return "xml"
|
|
if "@prefix" in stripped or "@base" in stripped:
|
|
return "turtle"
|
|
# N-Triples blank-node subject: "_:word <predicate-uri> ..."
|
|
# URI-subject N-Triples ("<uri> <uri>") are already caught by the XML
|
|
# branch above, so only the blank-node form needs to be checked here.
|
|
# Plain string ops avoid the polynomial regex that CodeQL flags (py/polynomial-redos).
|
|
if stripped.startswith("_:") and " <" in stripped:
|
|
return "nt"
|
|
return "turtle"
|
|
|
|
|
|
def _normalize_format(fmt: Optional[str]) -> str:
|
|
if not fmt:
|
|
return "turtle"
|
|
lower = fmt.strip().lower()
|
|
return _FORMAT_ALIASES.get(lower, lower)
|
|
|
|
|
|
def _validate_fetch_url(url: str) -> None:
|
|
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise HTTPException(status_code=422, detail="Only http and https URLs are allowed.")
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
raise HTTPException(status_code=422, detail="Invalid URL: missing hostname.")
|
|
try:
|
|
addrinfos = socket.getaddrinfo(hostname, None)
|
|
except socket.gaierror as exc:
|
|
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
|
|
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
|
try:
|
|
ip = ipaddress.ip_address(sockaddr[0])
|
|
except ValueError:
|
|
continue
|
|
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
|
|
)
|
|
|
|
|
|
def _fetch_url_sync(url: str) -> bytes:
|
|
_validate_fetch_url(url)
|
|
import requests as _req
|
|
try:
|
|
resp = _req.get(
|
|
url,
|
|
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
|
|
timeout=30,
|
|
stream=True,
|
|
allow_redirects=True,
|
|
)
|
|
resp.raise_for_status()
|
|
chunks: List[bytes] = []
|
|
total = 0
|
|
for chunk in resp.iter_content(65536):
|
|
total += len(chunk)
|
|
if total > _MAX_FETCH_BYTES:
|
|
raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.")
|
|
chunks.append(chunk)
|
|
return b"".join(chunks)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=502, detail=f"Could not fetch {url}: {exc}") from exc
|
|
|
|
|
|
def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
|
|
"""Return (nodes, edges, metadata). Raises HTTPException on failure."""
|
|
try:
|
|
import rdflib
|
|
except ImportError:
|
|
raise HTTPException(status_code=501, detail="rdflib is not installed.")
|
|
|
|
fmt_map = {
|
|
"turtle": "turtle", "xml": "xml", "nt": "nt",
|
|
"json-ld": "json-ld", "n3": "n3",
|
|
}
|
|
parse_fmt = fmt_map.get(fmt, "turtle")
|
|
|
|
g = rdflib.Graph()
|
|
try:
|
|
_safe_parse_rdf(g, content, parse_fmt)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=422, detail=f"RDF parse error: {exc}") from exc
|
|
|
|
OWL = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
|
|
RDF = rdflib.RDF
|
|
RDFS = rdflib.RDFS
|
|
SKOS = rdflib.Namespace("http://www.w3.org/2004/02/skos/core#")
|
|
DCT = rdflib.Namespace("http://purl.org/dc/terms/")
|
|
DC = rdflib.Namespace("http://purl.org/dc/elements/1.1/")
|
|
|
|
metadata: Dict[str, Any] = {}
|
|
|
|
for subj in g.subjects(RDF.type, OWL.Ontology):
|
|
metadata["uri"] = str(subj)
|
|
for pred, obj in g.predicate_objects(subj):
|
|
p = str(pred)
|
|
if p in {str(RDFS.label), str(DCT.title), str(DC.title)}:
|
|
metadata.setdefault("name", str(obj))
|
|
elif p in {str(RDFS.comment), str(DCT.description), str(DC.description)}:
|
|
metadata.setdefault("description", str(obj))
|
|
elif p == str(OWL.versionInfo):
|
|
metadata.setdefault("version", str(obj))
|
|
elif p in {str(DCT.license), str(DC.rights)}:
|
|
metadata.setdefault("license", str(obj))
|
|
break
|
|
|
|
if "uri" not in metadata:
|
|
for subj in g.subjects(RDF.type, SKOS.ConceptScheme):
|
|
metadata["uri"] = str(subj)
|
|
for pred, obj in g.predicate_objects(subj):
|
|
p = str(pred)
|
|
if p in {str(SKOS.prefLabel), str(DCT.title), str(DC.title)}:
|
|
metadata.setdefault("name", str(obj))
|
|
elif p in {str(SKOS.definition), str(DCT.description)}:
|
|
metadata.setdefault("description", str(obj))
|
|
break
|
|
|
|
if "uri" not in metadata:
|
|
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
|
|
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
|
|
metadata["triple_count"] = len(g)
|
|
|
|
# Collect literal properties per subject
|
|
literal_props: Dict[str, Dict[str, str]] = {}
|
|
for subj, pred, obj in g:
|
|
if isinstance(subj, rdflib.BNode) or not isinstance(obj, rdflib.Literal):
|
|
continue
|
|
sid = str(subj)
|
|
pk = _uri_to_prefix(str(pred))
|
|
literal_props.setdefault(sid, {})[pk] = str(obj)
|
|
|
|
# Build nodes from rdf:type statements
|
|
seen_ids: set = set()
|
|
nodes: List[Dict[str, Any]] = []
|
|
for subj, _, type_obj in g.triples((None, RDF.type, None)):
|
|
if isinstance(subj, rdflib.BNode):
|
|
continue
|
|
sid = str(subj)
|
|
ntype = _uri_to_prefix(str(type_obj))
|
|
if sid in seen_ids:
|
|
continue
|
|
seen_ids.add(sid)
|
|
props = dict(literal_props.get(sid, {}))
|
|
props["uri"] = sid
|
|
label = (
|
|
props.get("rdfs:label")
|
|
or props.get("skos:prefLabel")
|
|
or props.get("dcterms:title")
|
|
or sid.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
|
|
)
|
|
nodes.append({"id": sid, "type": ntype, "content": label, "properties": props})
|
|
|
|
# Build edges from non-literal object statements
|
|
edges: List[Dict[str, Any]] = []
|
|
for subj, pred, obj in g:
|
|
if isinstance(subj, rdflib.BNode) or isinstance(obj, (rdflib.Literal, rdflib.BNode)):
|
|
continue
|
|
edges.append({
|
|
"source": str(subj),
|
|
"target": str(obj),
|
|
"type": _uri_to_prefix(str(pred)),
|
|
"weight": 1.0,
|
|
})
|
|
|
|
return nodes, edges, metadata
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry endpoints (all specific paths before wildcard)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/registry", response_model=List[OntologyEntry])
|
|
async def list_registry(
|
|
request: Request,
|
|
q: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
format: Optional[str] = Query(None),
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
registry = _get_registry(request)
|
|
|
|
# Discover ontology-type nodes from live graph not yet registered
|
|
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
|
|
|
# Count entity types per ontology URI via scheme_uri property
|
|
class_counts: Dict[str, int] = {}
|
|
concept_counts: Dict[str, int] = {}
|
|
prop_counts: Dict[str, int] = {}
|
|
implicit: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for node in all_nodes:
|
|
ntype = node.get("type", "")
|
|
nid = node.get("id", "")
|
|
etype = _classify_node_type(ntype)
|
|
scheme_uri = node.get("properties", {}).get("scheme_uri") or node.get("properties", {}).get("uri")
|
|
|
|
if etype == "ontology" or etype == "scheme":
|
|
if nid and nid not in registry:
|
|
implicit[nid] = node
|
|
elif scheme_uri:
|
|
if etype == "class":
|
|
class_counts[scheme_uri] = class_counts.get(scheme_uri, 0) + 1
|
|
elif etype == "concept":
|
|
concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
|
|
elif etype == "property":
|
|
prop_counts[scheme_uri] = prop_counts.get(scheme_uri, 0) + 1
|
|
|
|
result: List[OntologyEntry] = []
|
|
|
|
def _matches(name: str, uri: str, desc: str) -> bool:
|
|
if not q:
|
|
return True
|
|
ql = q.lower()
|
|
return any(ql in t.lower() for t in [name, uri, desc] if t)
|
|
|
|
for entry in registry.values():
|
|
if status and entry.status != status:
|
|
continue
|
|
if format and entry.format.lower() != format.lower():
|
|
continue
|
|
if not _matches(entry.name, entry.uri, entry.description or ""):
|
|
continue
|
|
updated = entry.model_copy(update={
|
|
"class_count": class_counts.get(entry.uri, entry.class_count),
|
|
"concept_count": concept_counts.get(entry.uri, entry.concept_count),
|
|
"property_count": prop_counts.get(entry.uri, entry.property_count),
|
|
})
|
|
result.append(updated)
|
|
|
|
for nid, node in implicit.items():
|
|
props = node.get("properties", {})
|
|
name = _node_label(node) or nid
|
|
if not _matches(name, nid, props.get("description", "")):
|
|
continue
|
|
result.append(OntologyEntry(
|
|
uri=nid,
|
|
name=name,
|
|
description=props.get("description"),
|
|
format=props.get("format", "unknown"),
|
|
status="external",
|
|
version=props.get("version") or props.get("owl:versionInfo"),
|
|
class_count=class_counts.get(nid, 0),
|
|
concept_count=concept_counts.get(nid, 0),
|
|
property_count=prop_counts.get(nid, 0),
|
|
loaded_at=props.get("loaded_at", ""),
|
|
enabled=True,
|
|
))
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/preview", response_model=OntologyPreview)
|
|
async def preview_ontology(body: PreviewOntologyRequest):
|
|
if not body.url and not body.content:
|
|
raise HTTPException(status_code=422, detail="Provide either url or content.")
|
|
|
|
if body.url:
|
|
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
|
|
content_str = raw.decode("utf-8", errors="replace")
|
|
else:
|
|
content_str = body.content or ""
|
|
|
|
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
|
|
|
|
try:
|
|
_, _, metadata = await asyncio.to_thread(
|
|
_parse_rdf_sync, content_str.encode("utf-8"), fmt
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
|
|
|
|
return OntologyPreview(
|
|
uri=metadata.get("uri", ""),
|
|
name=metadata.get("name", ""),
|
|
description=metadata.get("description"),
|
|
namespace=_extract_namespace(metadata.get("uri", "")),
|
|
version=metadata.get("version"),
|
|
license=metadata.get("license"),
|
|
format=fmt,
|
|
estimated_triples=metadata.get("triple_count", 0),
|
|
source_url=body.url,
|
|
)
|
|
|
|
|
|
@router.post("/load", response_model=LoadOntologyResponse)
|
|
async def load_ontology(
|
|
request: Request,
|
|
body: LoadOntologyRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
if not body.url and not body.content:
|
|
raise HTTPException(status_code=422, detail="Provide either url or content.")
|
|
|
|
if body.url:
|
|
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
|
|
content_str = raw.decode("utf-8", errors="replace")
|
|
else:
|
|
content_str = body.content or ""
|
|
|
|
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
|
|
suffix = _INGEST_FORMAT_SUFFIXES.get(fmt)
|
|
if suffix is None:
|
|
raise HTTPException(status_code=422, detail=f"Unsupported ontology format: {fmt}")
|
|
|
|
try:
|
|
# Try to use OntologyIngestor for proper parsing and conversion
|
|
from ...ingest.ontology_ingestor import OntologyIngestor
|
|
from tempfile import NamedTemporaryFile
|
|
|
|
ingestor = OntologyIngestor()
|
|
|
|
# Write content to temporary file for ingestion
|
|
with NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") as temp_file:
|
|
temp_file.write(content_str)
|
|
temp_path = temp_file.name
|
|
|
|
try:
|
|
# Use OntologyIngestor to parse and convert
|
|
ontology_data = await asyncio.to_thread(
|
|
ingestor.ingest_ontology,
|
|
temp_path,
|
|
format=fmt
|
|
)
|
|
if not ontology_data.data.get("classes") and not ontology_data.data.get("properties"):
|
|
raise ValueError("No OWL classes or properties found by OntologyIngestor")
|
|
|
|
# Convert to graph nodes/edges using ontology data
|
|
nodes, edges = await asyncio.to_thread(
|
|
_convert_ontology_to_graph,
|
|
ontology_data.data
|
|
)
|
|
|
|
try:
|
|
nodes_added, edges_added = await asyncio.to_thread(
|
|
session.add_nodes_and_edges, nodes, edges
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
# Register in registry
|
|
registry = _get_registry(request)
|
|
ontology_uri = ontology_data.data.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
|
|
registry[ontology_uri] = OntologyEntry(
|
|
uri=ontology_uri,
|
|
name=ontology_data.data.get("name", "Imported Ontology"),
|
|
description=ontology_data.data.get("description"),
|
|
format=fmt,
|
|
status="external",
|
|
version=ontology_data.data.get("version", "1.0"),
|
|
class_count=len([n for n in nodes if n.get("type") in _CLASS_TYPES]),
|
|
concept_count=len([n for n in nodes if n.get("type") in _CONCEPT_TYPES]),
|
|
property_count=len([n for n in nodes if n.get("type") in _PROPERTY_TYPES]),
|
|
loaded_at=datetime.now(UTC).isoformat(),
|
|
enabled=True,
|
|
tags=body.tags,
|
|
source_url=body.url,
|
|
)
|
|
|
|
return LoadOntologyResponse(
|
|
uri=ontology_uri,
|
|
name=ontology_data.data.get("name", "Imported Ontology"),
|
|
nodes_added=nodes_added,
|
|
edges_added=edges_added,
|
|
format=fmt,
|
|
)
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
try:
|
|
os.unlink(temp_path)
|
|
except OSError as cleanup_exc:
|
|
logger.debug("Failed to remove temporary ontology file: %s", cleanup_exc)
|
|
|
|
except HTTPException:
|
|
# Re-raise HTTPExceptions we deliberately raised above (e.g. the 422 from
|
|
# SKOS cycle validation) instead of letting the broad `except Exception`
|
|
# below mask them as an ingestor failure and silently retry via the
|
|
# fallback parser.
|
|
raise
|
|
except Exception as ingest_exc:
|
|
logger.warning(f"OntologyIngestor failed, falling back to basic parsing: {ingest_exc}")
|
|
|
|
# Fallback to basic parsing
|
|
nodes, edges, metadata = await asyncio.to_thread(
|
|
_parse_rdf_sync, content_str.encode("utf-8"), fmt
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
|
|
|
|
# Fallback path - use basic parsing
|
|
try:
|
|
nodes_added, edges_added = await asyncio.to_thread(
|
|
session.add_nodes_and_edges, nodes, edges
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
registry = _get_registry(request)
|
|
ontology_uri = metadata.get("uri", f"temp:{uuid.uuid4().hex[:12]}")
|
|
registry[ontology_uri] = OntologyEntry(
|
|
uri=ontology_uri,
|
|
name=metadata.get("name", "Imported Ontology"),
|
|
description=metadata.get("description"),
|
|
format=fmt,
|
|
status="external",
|
|
version=metadata.get("version", "1.0"),
|
|
class_count=sum(1 for n in nodes if n.get("type") in _CLASS_TYPES),
|
|
concept_count=sum(1 for n in nodes if n.get("type") in _CONCEPT_TYPES),
|
|
property_count=sum(1 for n in nodes if n.get("type") in _PROPERTY_TYPES),
|
|
loaded_at=datetime.now(UTC).isoformat(),
|
|
enabled=True,
|
|
tags=body.tags,
|
|
source_url=body.url,
|
|
)
|
|
|
|
return LoadOntologyResponse(
|
|
uri=ontology_uri,
|
|
name=metadata.get("name", "Imported Ontology"),
|
|
nodes_added=nodes_added,
|
|
edges_added=edges_added,
|
|
format=fmt,
|
|
)
|
|
|
|
|
|
@router.post("/create", response_model=LoadOntologyResponse)
|
|
async def create_ontology(
|
|
request: Request,
|
|
body: CreateOntologyRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Create ontology from scratch, sample data, or text using OntologyEngine."""
|
|
ns = body.namespace.rstrip("/#")
|
|
onto_uri = f"{ns}#ontology"
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
if body.provider or body.model:
|
|
engine_config["provider"] = body.provider
|
|
engine_config["model"] = body.model
|
|
|
|
nodes: List[Dict[str, Any]] = [{
|
|
"id": onto_uri,
|
|
"type": "owl:Ontology",
|
|
"content": body.name,
|
|
"properties": {
|
|
"rdfs:label": body.name,
|
|
"rdfs:comment": body.description or "",
|
|
"namespace": body.namespace,
|
|
},
|
|
}]
|
|
edges: List[Dict[str, Any]] = []
|
|
|
|
if body.mode == "data" and body.sample_data:
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
engine = OntologyEngine(**engine_config)
|
|
result = await asyncio.to_thread(engine.from_data, body.sample_data)
|
|
|
|
# Convert OntologyEngine result to graph nodes/edges
|
|
if isinstance(result, dict):
|
|
for cls in result.get("classes", []):
|
|
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
|
|
nodes.append({
|
|
"id": cls_uri,
|
|
"type": "owl:Class",
|
|
"content": cls.get("name", ""),
|
|
"properties": {
|
|
"rdfs:label": cls.get("name", ""),
|
|
"rdfs:comment": cls.get("description", ""),
|
|
},
|
|
})
|
|
|
|
# Add property edges
|
|
for prop in result.get("properties", []):
|
|
prop_uri = f"{ns}/{prop.get('name', uuid.uuid4().hex[:6])}"
|
|
|
|
nodes.append({
|
|
"id": prop_uri,
|
|
"type": "owl:ObjectProperty",
|
|
"content": prop.get("name", ""),
|
|
"properties": {"rdfs:label": prop.get("name", "")},
|
|
})
|
|
|
|
# Only create domain/range edges if domain/range are specified
|
|
domain = prop.get('domain')
|
|
if domain and domain.strip():
|
|
domain_uri = f"{ns}/{domain}"
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": domain_uri,
|
|
"type": "rdfs:domain",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
range_val = prop.get('range')
|
|
if range_val and range_val.strip():
|
|
range_uri = f"{ns}/{range_val}"
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": range_uri,
|
|
"type": "rdfs:range",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
# Add subclass edges
|
|
for cls in result.get("classes", []):
|
|
cls_uri = f"{ns}/{cls.get('name', '')}"
|
|
for parent in cls.get("superclasses", []):
|
|
parent_uri = f"{ns}/{parent}"
|
|
edges.append({
|
|
"source": cls_uri,
|
|
"target": parent_uri,
|
|
"type": "rdfs:subClassOf",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
logger.info(f"Generated ontology from sample data with {len(nodes)} nodes, {len(edges)} edges")
|
|
|
|
except Exception as exc:
|
|
logger.exception("Failed to generate ontology from sample data; aborting ontology creation.")
|
|
raise HTTPException(status_code=500, detail=f"Ontology generation failed: {exc}") from exc
|
|
|
|
elif body.mode == "text" and body.schema_text:
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
engine = OntologyEngine(**engine_config)
|
|
result = await asyncio.to_thread(engine.from_text, body.schema_text, provider=body.provider, model=body.model)
|
|
|
|
# Convert OntologyEngine result to graph nodes/edges
|
|
if isinstance(result, dict):
|
|
for cls in result.get("classes", []):
|
|
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
|
|
nodes.append({
|
|
"id": cls_uri,
|
|
"type": "owl:Class",
|
|
"content": cls.get("name", ""),
|
|
"properties": {
|
|
"rdfs:label": cls.get("name", ""),
|
|
"rdfs:comment": cls.get("description", ""),
|
|
},
|
|
})
|
|
|
|
# Add property edges
|
|
for prop in result.get("properties", []):
|
|
prop_uri = f"{ns}/{prop.get('name', uuid.uuid4().hex[:6])}"
|
|
|
|
nodes.append({
|
|
"id": prop_uri,
|
|
"type": "owl:ObjectProperty",
|
|
"content": prop.get("name", ""),
|
|
"properties": {"rdfs:label": prop.get("name", "")},
|
|
})
|
|
|
|
# Only create domain/range edges if domain/range are specified
|
|
domain = prop.get('domain')
|
|
if domain and domain.strip():
|
|
domain_uri = f"{ns}/{domain}"
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": domain_uri,
|
|
"type": "rdfs:domain",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
range_val = prop.get('range')
|
|
if range_val and range_val.strip():
|
|
range_uri = f"{ns}/{range_val}"
|
|
edges.append({
|
|
"source": prop_uri,
|
|
"target": range_uri,
|
|
"type": "rdfs:range",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
# Add subclass edges
|
|
for cls in result.get("classes", []):
|
|
cls_uri = f"{ns}/{cls.get('name', '')}"
|
|
for parent in cls.get("superclasses", []):
|
|
parent_uri = f"{ns}/{parent}"
|
|
edges.append({
|
|
"source": cls_uri,
|
|
"target": parent_uri,
|
|
"type": "rdfs:subClassOf",
|
|
"weight": 1.0,
|
|
})
|
|
|
|
logger.info(f"Generated ontology from text with {len(nodes)} nodes, {len(edges)} edges")
|
|
|
|
except Exception as exc:
|
|
logger.exception("Failed to generate ontology from schema text; aborting ontology creation.")
|
|
raise HTTPException(status_code=500, detail=f"Ontology generation failed: {exc}") from exc
|
|
|
|
try:
|
|
nodes_added, edges_added = await asyncio.to_thread(
|
|
session.add_nodes_and_edges, nodes, edges
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
registry = _get_registry(request)
|
|
registry[onto_uri] = OntologyEntry(
|
|
uri=onto_uri,
|
|
name=body.name,
|
|
description=body.description,
|
|
format="turtle",
|
|
status="draft",
|
|
version="0.1.0",
|
|
class_count=sum(1 for n in nodes if n.get("type") == "owl:Class"),
|
|
loaded_at=datetime.now(UTC).isoformat(),
|
|
enabled=True,
|
|
tags=body.tags,
|
|
)
|
|
|
|
return LoadOntologyResponse(
|
|
uri=onto_uri, name=body.name,
|
|
nodes_added=nodes_added, edges_added=edges_added, format="turtle",
|
|
)
|
|
|
|
|
|
@router.get("/search", response_model=List[OntologySearchResult])
|
|
async def search_entities(
|
|
q: str = Query(..., min_length=1),
|
|
entity_type: Optional[str] = Query(None),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
# Use the session's indexed search; over-fetch to allow post-filtering by entity type
|
|
raw_hits = await asyncio.to_thread(session.search, q, limit * 6)
|
|
results: List[OntologySearchResult] = []
|
|
|
|
for hit in raw_hits:
|
|
node = hit.get("node", hit) # session.search returns {"node": ..., "score": ...}
|
|
ntype = node.get("type", "")
|
|
if ntype not in _SEARCHABLE_TYPES:
|
|
continue
|
|
etype = _classify_node_type(ntype)
|
|
if entity_type and etype != entity_type:
|
|
continue
|
|
|
|
label = _node_label(node)
|
|
props = node.get("properties", {})
|
|
definition = (
|
|
props.get("rdfs:comment")
|
|
or props.get("skos:definition")
|
|
or props.get("description")
|
|
)
|
|
|
|
results.append(OntologySearchResult(
|
|
uri=node.get("id", ""),
|
|
label=label,
|
|
type=ntype,
|
|
entity_type=etype,
|
|
definition=definition,
|
|
source_ontology=props.get("scheme_uri"),
|
|
namespace_prefix=_extract_namespace(node.get("id", "")),
|
|
))
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
return results
|
|
|
|
|
|
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
|
|
async def get_entity_detail(
|
|
entity_uri: str,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
node = await asyncio.to_thread(session.get_node, entity_uri)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail="Entity not found.")
|
|
|
|
props = node.get("properties", {})
|
|
ntype = node.get("type", "")
|
|
label = _node_label(node)
|
|
definition = props.get("rdfs:comment") or props.get("skos:definition") or props.get("description")
|
|
|
|
out_edges, _ = await asyncio.to_thread(session.get_edges, source=entity_uri, skip=0, limit=9999)
|
|
in_edges, _ = await asyncio.to_thread(session.get_edges, target=entity_uri, skip=0, limit=9999)
|
|
|
|
superclasses = [e["target"] for e in out_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
|
|
subclasses = [e["source"] for e in in_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
|
|
domain = [e["target"] for e in out_edges if e.get("type") == "rdfs:domain"]
|
|
range_ = [e["target"] for e in out_edges if e.get("type") == "rdfs:range"]
|
|
|
|
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
|
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
|
|
|
|
return EntityDetailResponse(
|
|
uri=entity_uri, label=label,
|
|
type=ntype, entity_type=_classify_node_type(ntype),
|
|
definition=definition,
|
|
source_ontology=props.get("scheme_uri"),
|
|
superclasses=superclasses, subclasses=subclasses,
|
|
domain=domain, range=range_,
|
|
instance_count=instance_count, properties=props,
|
|
)
|
|
|
|
|
|
@router.get("/skos/schemes", response_model=List[SKOSScheme])
|
|
async def list_skos_schemes(session: GraphSession = Depends(get_session)):
|
|
"""List SKOS concept schemes using OntologyEngine.list_vocabularies()."""
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Use OntologyEngine.list_vocabularies
|
|
vocabularies = await asyncio.to_thread(engine.list_vocabularies)
|
|
|
|
# Count concepts per scheme
|
|
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
|
concept_counts: Dict[str, int] = {}
|
|
for node in all_nodes:
|
|
scheme_uri = node.get("properties", {}).get("scheme_uri")
|
|
if scheme_uri and node.get("type") in _CONCEPT_TYPES:
|
|
concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
|
|
|
|
return [
|
|
SKOSScheme(
|
|
uri=vocab["uri"],
|
|
title=vocab["label"] or vocab["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1],
|
|
description=None,
|
|
concept_count=concept_counts.get(vocab["uri"], 0),
|
|
)
|
|
for vocab in vocabularies
|
|
]
|
|
except Exception as exc:
|
|
logger.warning(f"OntologyEngine.list_vocabularies failed, falling back to session: {exc}")
|
|
# Fallback to session-based implementation
|
|
nodes, _ = await asyncio.to_thread(
|
|
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
|
|
)
|
|
all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
|
concept_counts: Dict[str, int] = {}
|
|
for edge in all_edges:
|
|
if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
|
|
concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
|
|
elif edge.get("type") == "skos:hasTopConcept":
|
|
concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
|
|
|
|
result = []
|
|
for node in nodes:
|
|
props = node.get("properties", {})
|
|
nid = node.get("id", "")
|
|
result.append(SKOSScheme(
|
|
uri=nid,
|
|
title=_node_label(node),
|
|
description=props.get("description") or props.get("skos:definition"),
|
|
concept_count=concept_counts.get(nid, 0),
|
|
))
|
|
return result
|
|
|
|
|
|
@router.post("/skos/search", response_model=List[OntologySearchResult])
|
|
async def search_skos_concepts(
|
|
body: SKOSConceptSearchRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Search SKOS concepts using OntologyEngine.search_concepts()."""
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Use OntologyEngine.search_concepts
|
|
concepts = await asyncio.to_thread(
|
|
engine.search_concepts,
|
|
body.query,
|
|
scheme_uri=body.scheme_uri
|
|
)
|
|
|
|
return [
|
|
OntologySearchResult(
|
|
uri=concept["uri"],
|
|
label=concept["label"],
|
|
type="skos:Concept",
|
|
entity_type="concept",
|
|
definition=None,
|
|
source_ontology=body.scheme_uri,
|
|
namespace_prefix=_extract_namespace(concept["uri"]),
|
|
)
|
|
for concept in concepts
|
|
]
|
|
except Exception as exc:
|
|
logger.warning(f"OntologyEngine.search_concepts failed, using fallback: {exc}")
|
|
# Fallback to session-based search
|
|
raw_hits = await asyncio.to_thread(session.search, body.query, 300)
|
|
results: List[OntologySearchResult] = []
|
|
|
|
for hit in raw_hits:
|
|
node = hit.get("node", hit)
|
|
ntype = node.get("type", "")
|
|
if ntype not in _CONCEPT_TYPES:
|
|
continue
|
|
if body.scheme_uri:
|
|
scheme = node.get("properties", {}).get("scheme_uri")
|
|
if scheme != body.scheme_uri:
|
|
continue
|
|
|
|
label = _node_label(node)
|
|
props = node.get("properties", {})
|
|
definition = (
|
|
props.get("rdfs:comment")
|
|
or props.get("skos:definition")
|
|
or props.get("description")
|
|
)
|
|
|
|
results.append(OntologySearchResult(
|
|
uri=node.get("id", ""),
|
|
label=label,
|
|
type=ntype,
|
|
entity_type="concept",
|
|
definition=definition,
|
|
source_ontology=props.get("scheme_uri"),
|
|
namespace_prefix=_extract_namespace(node.get("id", "")),
|
|
))
|
|
if len(results) >= 50:
|
|
break
|
|
|
|
return results
|
|
|
|
|
|
@router.get("/skos/concept/{concept_uri:path}", response_model=SKOSConceptDetail)
|
|
async def get_skos_concept(
|
|
concept_uri: str,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Get SKOS concept detail using OntologyEngine.list_concepts() and search_concepts()."""
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Try to get scheme_uri from node first
|
|
node = await asyncio.to_thread(session.get_node, concept_uri)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail="Concept not found.")
|
|
|
|
scheme_uri = node.get("properties", {}).get("scheme_uri")
|
|
|
|
# Use OntologyEngine.list_concepts if scheme_uri is known
|
|
if scheme_uri:
|
|
concepts = await asyncio.to_thread(engine.list_concepts, scheme_uri)
|
|
concept_data = next((c for c in concepts if c["uri"] == concept_uri), None)
|
|
|
|
if concept_data:
|
|
return SKOSConceptDetail(
|
|
uri=concept_data["uri"],
|
|
pref_label=concept_data["pref_label"],
|
|
alt_labels=concept_data.get("alt_labels", []),
|
|
hidden_labels=[],
|
|
definition=None,
|
|
scope_note=None,
|
|
editorial_note=None,
|
|
broader=[],
|
|
narrower=[],
|
|
related=[],
|
|
exact_match=[],
|
|
close_match=[],
|
|
broad_match=[],
|
|
narrow_match=[],
|
|
scheme_uri=scheme_uri,
|
|
)
|
|
|
|
# Fallback to session-based implementation
|
|
props = node.get("properties", {})
|
|
out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
|
|
in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
|
|
|
|
def collect_out(rel: str) -> List[str]:
|
|
return [e["target"] for e in out_edges if e.get("type") == rel]
|
|
|
|
def collect_in(rel: str) -> List[str]:
|
|
return [e["source"] for e in in_edges if e.get("type") == rel]
|
|
|
|
pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
|
|
alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
|
|
if isinstance(alt_labels, str):
|
|
alt_labels = [alt_labels]
|
|
hidden_labels = props.get("skos:hiddenLabel") or []
|
|
if isinstance(hidden_labels, str):
|
|
hidden_labels = [hidden_labels]
|
|
|
|
if not scheme_uri:
|
|
candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
|
|
scheme_uri = candidates[0] if candidates else None
|
|
|
|
return SKOSConceptDetail(
|
|
uri=concept_uri,
|
|
pref_label=pref_label,
|
|
alt_labels=list(alt_labels),
|
|
hidden_labels=list(hidden_labels),
|
|
definition=props.get("definition") or props.get("skos:definition"),
|
|
scope_note=props.get("skos:scopeNote"),
|
|
editorial_note=props.get("skos:editorialNote"),
|
|
broader=collect_out("skos:broader") + collect_in("skos:narrower"),
|
|
narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
|
|
related=collect_out("skos:related"),
|
|
exact_match=collect_out("skos:exactMatch"),
|
|
close_match=collect_out("skos:closeMatch"),
|
|
broad_match=collect_out("skos:broadMatch"),
|
|
narrow_match=collect_out("skos:narrowMatch"),
|
|
scheme_uri=scheme_uri,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning(f"OntologyEngine SKOS methods failed, using fallback: {exc}")
|
|
# Fallback to session-based implementation
|
|
node = await asyncio.to_thread(session.get_node, concept_uri)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail="Concept not found.")
|
|
|
|
props = node.get("properties", {})
|
|
out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
|
|
in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
|
|
|
|
def collect_out(rel: str) -> List[str]:
|
|
return [e["target"] for e in out_edges if e.get("type") == rel]
|
|
|
|
def collect_in(rel: str) -> List[str]:
|
|
return [e["source"] for e in in_edges if e.get("type") == rel]
|
|
|
|
pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
|
|
alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
|
|
if isinstance(alt_labels, str):
|
|
alt_labels = [alt_labels]
|
|
hidden_labels = props.get("skos:hiddenLabel") or []
|
|
if isinstance(hidden_labels, str):
|
|
hidden_labels = [hidden_labels]
|
|
|
|
scheme_uri = props.get("scheme_uri")
|
|
if not scheme_uri:
|
|
candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
|
|
scheme_uri = candidates[0] if candidates else None
|
|
|
|
return SKOSConceptDetail(
|
|
uri=concept_uri,
|
|
pref_label=pref_label,
|
|
alt_labels=list(alt_labels),
|
|
hidden_labels=list(hidden_labels),
|
|
definition=props.get("definition") or props.get("skos:definition"),
|
|
scope_note=props.get("skos:scopeNote"),
|
|
editorial_note=props.get("skos:editorialNote"),
|
|
broader=collect_out("skos:broader") + collect_in("skos:narrower"),
|
|
narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
|
|
related=collect_out("skos:related"),
|
|
exact_match=collect_out("skos:exactMatch"),
|
|
close_match=collect_out("skos:closeMatch"),
|
|
broad_match=collect_out("skos:broadMatch"),
|
|
narrow_match=collect_out("skos:narrowMatch"),
|
|
scheme_uri=scheme_uri,
|
|
)
|
|
|
|
|
|
# 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)
|
|
|
|
try:
|
|
health_nodes, health_edges = await _fetch_analysis_graph(session, uri, "shacl-health")
|
|
shacl_turtle, _ = await _generated_shacl_for_uri(
|
|
request, session, uri, nodes=health_nodes, edges=health_edges
|
|
)
|
|
data_graph_turtle = await _data_graph_turtle_for_uri(
|
|
request, session, uri, nodes=health_nodes, edges=health_edges
|
|
)
|
|
from ...ontology import OntologyEngine
|
|
engine = OntologyEngine()
|
|
report = await asyncio.to_thread(
|
|
engine.validate_graph,
|
|
data_graph_turtle,
|
|
shacl=shacl_turtle,
|
|
data_graph_format="turtle",
|
|
shacl_format="turtle",
|
|
)
|
|
warning_count = len(report.warnings)
|
|
if not report.conforms:
|
|
shacl_score = max(0.0, 100.0 - (report.violation_count * 20.0))
|
|
elif warning_count:
|
|
shacl_score = max(0.0, 100.0 - (warning_count * 5.0))
|
|
else:
|
|
shacl_score = 100.0
|
|
shacl_status = "ok" if report.conforms and not warning_count else "warning"
|
|
detail_parts = []
|
|
if not report.conforms:
|
|
detail_parts.append(f"{report.violation_count} SHACL violation(s)")
|
|
if warning_count:
|
|
detail_parts.append(f"{warning_count} SHACL warning(s)")
|
|
shacl_detail = (
|
|
"Graph conforms to all generated SHACL constraints."
|
|
if not detail_parts
|
|
else f"Graph has {', '.join(detail_parts)}."
|
|
)
|
|
shacl_dimension = HealthDimension(
|
|
key="shacl",
|
|
label="SHACL Conformance",
|
|
score=round(shacl_score, 1),
|
|
status=shacl_status,
|
|
detail=shacl_detail,
|
|
)
|
|
except GraphTruncationError as exc:
|
|
shacl_dimension = HealthDimension(
|
|
key="shacl",
|
|
label="SHACL Conformance",
|
|
score=0.0,
|
|
status="critical",
|
|
detail=str(exc),
|
|
)
|
|
except ImportError:
|
|
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.",
|
|
)
|
|
except Exception as exc:
|
|
shacl_dimension = HealthDimension(
|
|
key="shacl",
|
|
label="SHACL Conformance",
|
|
score=0.0,
|
|
status="critical",
|
|
detail=f"Live SHACL validation failed: {exc}",
|
|
)
|
|
|
|
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 _fetch_analysis_graph(
|
|
session: GraphSession,
|
|
uri: str,
|
|
log_tag: str,
|
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
"""Fetch nodes/edges for SHACL analysis, raising GraphTruncationError if oversized.
|
|
|
|
Shared by `_generated_shacl_for_uri` and `_data_graph_turtle_for_uri` so callers that
|
|
need both (e.g. `/health`) can fetch once and pass the results to both via `nodes=`/`edges=`.
|
|
"""
|
|
nodes, total_nodes = await asyncio.to_thread(session.get_nodes, skip=0, limit=_MAX_ANALYSIS_NODES)
|
|
edges, total_edges = await asyncio.to_thread(session.get_edges, skip=0, limit=_MAX_ANALYSIS_NODES)
|
|
if total_nodes > _MAX_ANALYSIS_NODES or total_edges > _MAX_ANALYSIS_NODES:
|
|
logger.warning(
|
|
"%s: graph for %s is too large to analyse (nodes=%d, edges=%d, limit=%d); skipping.",
|
|
log_tag, uri, total_nodes, total_edges, _MAX_ANALYSIS_NODES,
|
|
)
|
|
raise GraphTruncationError(
|
|
f"Graph size (nodes={total_nodes}, edges={total_edges}) exceeds maximum analysis limit ({_MAX_ANALYSIS_NODES}). "
|
|
"SHACL validation is skipped because the graph is too large to validate fully under current limits."
|
|
)
|
|
return nodes, edges
|
|
|
|
|
|
async def _generated_shacl_for_uri(
|
|
request: Request,
|
|
session: GraphSession,
|
|
uri: str,
|
|
quality_tier: str = "strict",
|
|
*,
|
|
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
edges: Optional[List[Dict[str, Any]]] = None,
|
|
) -> 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.")
|
|
|
|
if nodes is None or edges is None:
|
|
nodes, edges = await _fetch_analysis_graph(session, uri, "shacl-generate")
|
|
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)
|
|
|
|
|
|
async def _data_graph_turtle_for_uri(
|
|
request: Request,
|
|
session: GraphSession,
|
|
uri: str,
|
|
*,
|
|
nodes: Optional[List[Dict[str, Any]]] = None,
|
|
edges: Optional[List[Dict[str, Any]]] = None,
|
|
) -> str:
|
|
try:
|
|
import rdflib
|
|
except ImportError as exc:
|
|
raise ImportError("rdflib is not installed.") from exc
|
|
|
|
registry = {entry.uri: entry for entry in await _registry_entries(request, session)}
|
|
if uri not in registry:
|
|
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
|
|
|
if nodes is None or edges is None:
|
|
nodes, edges = await _fetch_analysis_graph(session, uri, "shacl-data-graph")
|
|
entities = _data_graph_entities(nodes, uri)
|
|
|
|
g = rdflib.Graph()
|
|
base_namespace = _ontology_namespace(uri)
|
|
|
|
OWL = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
|
|
RDF = rdflib.RDF
|
|
RDFS = rdflib.RDFS
|
|
SKOS = rdflib.Namespace("http://www.w3.org/2004/02/skos/core#")
|
|
DCT = rdflib.Namespace("http://purl.org/dc/terms/")
|
|
DC = rdflib.Namespace("http://purl.org/dc/elements/1.1/")
|
|
SH = rdflib.Namespace("http://www.w3.org/ns/shacl#")
|
|
XSD = rdflib.XSD
|
|
ONTO = rdflib.Namespace(base_namespace)
|
|
|
|
g.bind("owl", OWL)
|
|
g.bind("rdf", RDF)
|
|
g.bind("rdfs", RDFS)
|
|
g.bind("skos", SKOS)
|
|
g.bind("dct", DCT)
|
|
g.bind("dc", DC)
|
|
g.bind("sh", SH)
|
|
g.bind("xsd", XSD)
|
|
g.bind("onto", ONTO)
|
|
|
|
def _resolve_uri(val: str, base_ns: str) -> rdflib.URIRef:
|
|
val_str = str(val).strip()
|
|
if val_str == "a":
|
|
return rdflib.RDF.type
|
|
if val_str.startswith(("http://", "https://", "urn:", "ftp://")):
|
|
return rdflib.URIRef(val_str)
|
|
prefix_map = {
|
|
"owl": "http://www.w3.org/2002/07/owl#",
|
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
|
"skos": "http://www.w3.org/2004/02/skos/core#",
|
|
"dct": "http://purl.org/dc/terms/",
|
|
"dc": "http://purl.org/dc/elements/1.1/",
|
|
"sh": "http://www.w3.org/ns/shacl#",
|
|
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
|
"onto": base_ns,
|
|
}
|
|
if ":" in val_str and not val_str.startswith("/"):
|
|
prefix, _, rest = val_str.partition(":")
|
|
if prefix in prefix_map:
|
|
return rdflib.URIRef(prefix_map[prefix] + rest)
|
|
return rdflib.URIRef(val_str)
|
|
if val_str.startswith("#"):
|
|
val_str = val_str.lstrip("#")
|
|
if base_ns.endswith(("/", "#")):
|
|
return rdflib.URIRef(base_ns + val_str.lstrip("/"))
|
|
return rdflib.URIRef(base_ns + "#" + val_str.lstrip("/"))
|
|
|
|
_shorthand_types = {
|
|
"class": "owl:Class",
|
|
"object_property": "owl:ObjectProperty",
|
|
"datatype_property": "owl:DatatypeProperty",
|
|
"annotation_property": "owl:AnnotationProperty",
|
|
"property": "rdf:Property",
|
|
"individual": "owl:NamedIndividual",
|
|
"concept": "skos:Concept",
|
|
"scheme": "skos:ConceptScheme",
|
|
"ontology": "owl:Ontology",
|
|
}
|
|
_uri_predicates = {
|
|
"rdf:type",
|
|
"a",
|
|
"rdfs:subClassOf",
|
|
"rdfs:domain",
|
|
"rdfs:range",
|
|
"owl:equivalentClass",
|
|
"owl:equivalentProperty",
|
|
"owl:sameAs",
|
|
"skos:exactMatch",
|
|
"skos:closeMatch",
|
|
"skos:broadMatch",
|
|
"skos:narrowMatch",
|
|
"skos:relatedMatch",
|
|
"sh:targetClass",
|
|
"sh:targetNode",
|
|
}
|
|
_literal_predicates = {
|
|
"rdfs:label",
|
|
"rdfs:comment",
|
|
"skos:definition",
|
|
"skos:prefLabel",
|
|
"skos:altLabel",
|
|
"dct:description",
|
|
"dct:title",
|
|
"dc:title",
|
|
"dc:description",
|
|
"version",
|
|
}
|
|
_skip_keys = {
|
|
"id",
|
|
"type",
|
|
"content",
|
|
"valid_from",
|
|
"valid_until",
|
|
"scheme_uri",
|
|
"ontology_uri",
|
|
"ontology",
|
|
}
|
|
|
|
entity_ids = set()
|
|
for node in entities:
|
|
nid_str = str(node.get("id", "")).strip()
|
|
if not nid_str:
|
|
continue
|
|
entity_ids.add(nid_str)
|
|
subj = _resolve_uri(nid_str, base_namespace)
|
|
|
|
node_type = node.get("type", "")
|
|
if node_type:
|
|
for t in _as_uri_list(node_type):
|
|
t_mapped = _shorthand_types.get(str(t).lower(), str(t))
|
|
g.add((subj, rdflib.RDF.type, _resolve_uri(t_mapped, base_namespace)))
|
|
|
|
content = str(node.get("content", "")).strip()
|
|
props = node.get("properties", {}) or {}
|
|
if content and "rdfs:label" not in props and "pref_label" not in props and "label" not in props:
|
|
g.add((subj, RDFS.label, rdflib.Literal(content)))
|
|
|
|
for key, val in props.items():
|
|
if key in _skip_keys or val is None or val == "":
|
|
continue
|
|
pred = _resolve_uri(str(key), base_namespace)
|
|
for item in _as_uri_list(val) if isinstance(val, (list, dict)) else [val]:
|
|
if item is None or item == "":
|
|
continue
|
|
if isinstance(item, dict):
|
|
item = item.get("uri") or item.get("id") or item.get("@id")
|
|
if not item:
|
|
continue
|
|
if str(key) in _uri_predicates or (
|
|
str(key) not in _literal_predicates
|
|
and str(item).strip().startswith(("http://", "https://", "urn:"))
|
|
):
|
|
g.add((subj, pred, _resolve_uri(str(item), base_namespace)))
|
|
else:
|
|
g.add((subj, pred, rdflib.Literal(str(item))))
|
|
|
|
for edge in edges:
|
|
source = str(edge.get("source", edge.get("source_id", ""))).strip()
|
|
target = str(edge.get("target", edge.get("target_id", ""))).strip()
|
|
pred_str = str(edge.get("type", "related_to")).strip()
|
|
if not source or not target:
|
|
continue
|
|
if source in entity_ids or target in entity_ids:
|
|
g.add((
|
|
_resolve_uri(source, base_namespace),
|
|
_resolve_uri(pred_str, base_namespace),
|
|
_resolve_uri(target, base_namespace),
|
|
))
|
|
|
|
return await asyncio.to_thread(g.serialize, format="turtle")
|
|
|
|
|
|
@router.post("/shacl/generate", response_model=ShaclGenerateResponse)
|
|
async def generate_shacl(
|
|
request: Request,
|
|
body: ShaclGenerateRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
try:
|
|
shacl_turtle, shapes = await _generated_shacl_for_uri(request, session, body.uri, body.quality_tier)
|
|
except GraphTruncationError as exc:
|
|
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
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),
|
|
):
|
|
try:
|
|
shacl_turtle, shapes = await _generated_shacl_for_uri(request, session, uri)
|
|
except GraphTruncationError as exc:
|
|
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
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(
|
|
request: Request,
|
|
body: ShaclValidateRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
if not body.shacl_turtle.strip():
|
|
raise HTTPException(status_code=422, detail="SHACL Turtle cannot be empty.")
|
|
|
|
_shacl_bytes = body.shacl_turtle.encode("utf-8")
|
|
if len(_shacl_bytes) > _MAX_SHACL_TURTLE_BYTES:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="error",
|
|
message=(
|
|
f"SHACL Turtle size ({len(_shacl_bytes)} bytes) "
|
|
f"exceeds maximum allowed size ({_MAX_SHACL_TURTLE_BYTES} bytes)."
|
|
),
|
|
violations=[],
|
|
)
|
|
|
|
# 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")
|
|
if len(g) > _MAX_SHACL_TRIPLES:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="error",
|
|
message=(
|
|
f"SHACL graph triple count ({len(g)}) "
|
|
f"exceeds maximum allowed limit ({_MAX_SHACL_TRIPLES})."
|
|
),
|
|
violations=[],
|
|
)
|
|
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
|
|
|
|
if not body.uri:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="unavailable",
|
|
message=(
|
|
"Turtle parsed successfully. "
|
|
"No target ontology URI was provided — "
|
|
"specify 'uri' to execute live SHACL validation against an ontology data graph."
|
|
),
|
|
violations=[],
|
|
)
|
|
|
|
try:
|
|
data_graph_turtle = await _data_graph_turtle_for_uri(request, session, body.uri)
|
|
from ...ontology import OntologyEngine
|
|
engine = OntologyEngine()
|
|
semaphore = _get_shacl_semaphore()
|
|
|
|
async def _run_validation():
|
|
async with semaphore:
|
|
return await asyncio.to_thread(
|
|
engine.validate_graph,
|
|
data_graph_turtle,
|
|
shacl=body.shacl_turtle,
|
|
data_graph_format="turtle",
|
|
shacl_format="turtle",
|
|
)
|
|
|
|
report = await asyncio.wait_for(
|
|
_run_validation(),
|
|
timeout=_MAX_SHACL_TIMEOUT_SECONDS,
|
|
)
|
|
except (asyncio.TimeoutError, TimeoutError):
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="error",
|
|
message=(
|
|
f"SHACL validation timed out after {_MAX_SHACL_TIMEOUT_SECONDS} seconds."
|
|
),
|
|
violations=[],
|
|
)
|
|
except GraphTruncationError as exc:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="unavailable",
|
|
message=str(exc),
|
|
violations=[],
|
|
)
|
|
except ImportError as exc:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="unavailable",
|
|
message=(
|
|
"Turtle parsed successfully. "
|
|
f"Live SHACL validation is unavailable: {exc}"
|
|
),
|
|
violations=[],
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=False,
|
|
status="error",
|
|
message=f"SHACL validation error: {exc}",
|
|
violations=[],
|
|
)
|
|
|
|
violations = [
|
|
ShaclViolation(
|
|
node=str(v.focus_node) if v.focus_node is not None else None,
|
|
path=str(v.result_path) if v.result_path is not None else None,
|
|
severity=str(v.severity or "Violation"),
|
|
message=str(
|
|
v.message
|
|
or v.explanation
|
|
or f"SHACL constraint violation ({v.constraint}) on {v.focus_node}"
|
|
),
|
|
focus_node=str(v.focus_node) if v.focus_node is not None else None,
|
|
source_shape=str(v.shape) if v.shape is not None else None,
|
|
)
|
|
for v in [*report.violations, *report.warnings, *report.infos]
|
|
]
|
|
_result_counts = []
|
|
if report.violations:
|
|
_result_counts.append(f"{len(report.violations)} violation(s)")
|
|
if report.warnings:
|
|
_result_counts.append(f"{len(report.warnings)} warning(s)")
|
|
if report.infos:
|
|
_result_counts.append(f"{len(report.infos)} info result(s)")
|
|
summary_msg = (
|
|
f"SHACL validation found {', '.join(_result_counts)}."
|
|
if _result_counts
|
|
else "Graph conforms to SHACL shapes."
|
|
)
|
|
return ShaclValidationResponse(
|
|
uri=body.uri,
|
|
conforms=report.conforms,
|
|
status="success",
|
|
message=summary_msg,
|
|
violations=violations,
|
|
report_text=report.raw_report,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wildcard management endpoints (must come after specific routes)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.delete("/{ontology_uri:path}")
|
|
async def remove_ontology(ontology_uri: str, request: Request):
|
|
registry = _get_registry(request)
|
|
if ontology_uri not in registry:
|
|
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
|
del registry[ontology_uri]
|
|
return {"status": "removed", "uri": ontology_uri}
|
|
|
|
|
|
@router.patch("/{ontology_uri:path}/toggle", response_model=ToggleResponse)
|
|
async def toggle_ontology(ontology_uri: str, request: Request):
|
|
registry = _get_registry(request)
|
|
if ontology_uri not in registry:
|
|
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
|
entry = registry[ontology_uri]
|
|
entry.enabled = not entry.enabled
|
|
return ToggleResponse(uri=ontology_uri, enabled=entry.enabled)
|
|
|
|
|
|
@router.post("/{ontology_uri:path}/refresh", response_model=RefreshResponse)
|
|
async def refresh_ontology(
|
|
ontology_uri: str,
|
|
request: Request,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
registry = _get_registry(request)
|
|
if ontology_uri not in registry:
|
|
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
|
|
entry = registry[ontology_uri]
|
|
if not entry.source_url:
|
|
raise HTTPException(status_code=422, detail="No source URL to refresh from.")
|
|
|
|
raw = await asyncio.to_thread(_fetch_url_sync, entry.source_url)
|
|
content_str = raw.decode("utf-8", errors="replace")
|
|
|
|
try:
|
|
nodes, edges, _ = await asyncio.to_thread(
|
|
_parse_rdf_sync, content_str.encode("utf-8"), entry.format
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=422, detail=f"Refresh parse error: {exc}") from exc
|
|
|
|
try:
|
|
nodes_added, edges_added = await asyncio.to_thread(
|
|
session.add_nodes_and_edges, nodes, edges
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
entry.loaded_at = datetime.now(UTC).isoformat()
|
|
|
|
return RefreshResponse(uri=ontology_uri, nodes_added=nodes_added, edges_added=edges_added)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Draft endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.patch("/draft", response_model=DraftResponse)
|
|
async def save_draft(
|
|
request: Request,
|
|
body: DraftRequest,
|
|
):
|
|
"""Stage editor diffs as a draft with ChangeLogEntry metadata."""
|
|
drafts = _get_drafts(request)
|
|
draft_id = f"draft_{uuid.uuid4().hex[:12]}"
|
|
now = datetime.now(UTC).isoformat()
|
|
|
|
# Create ChangeLogEntry for audit trail
|
|
try:
|
|
from ...change_management.change_log import ChangeLogEntry
|
|
ChangeLogEntry.create_now(
|
|
author=body.author,
|
|
description=body.summary or f"Draft changes for {body.ontology_uri}",
|
|
change_id=draft_id
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to create ChangeLogEntry: {exc}")
|
|
|
|
draft = DraftResponse(
|
|
draft_id=draft_id,
|
|
ontology_uri=body.ontology_uri,
|
|
diff=body.diff,
|
|
author=body.author,
|
|
summary=body.summary,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
drafts[draft_id] = draft
|
|
return draft
|
|
|
|
|
|
@router.get("/drafts/{ontology_uri:path}", response_model=List[DraftResponse])
|
|
async def list_drafts(
|
|
ontology_uri: str,
|
|
request: Request,
|
|
):
|
|
"""Get staged draft diffs for an ontology."""
|
|
drafts = _get_drafts(request)
|
|
return [d for d in drafts.values() if d.ontology_uri == ontology_uri]
|
|
|
|
|
|
@router.get("/draft/{draft_id}", response_model=DraftResponse)
|
|
async def get_draft(
|
|
draft_id: str,
|
|
request: Request,
|
|
):
|
|
"""Get a specific draft by ID."""
|
|
drafts = _get_drafts(request)
|
|
if draft_id not in drafts:
|
|
raise HTTPException(status_code=404, detail="Draft not found.")
|
|
return drafts[draft_id]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Proposal endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/propose", response_model=ProposalResponse)
|
|
async def submit_proposal(
|
|
request: Request,
|
|
body: ProposalRequest,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Submit a change proposal with impact analysis and SHACL pre-validation."""
|
|
drafts = _get_drafts(request)
|
|
proposals = _get_proposals(request)
|
|
|
|
if body.draft_id not in drafts:
|
|
raise HTTPException(status_code=404, detail="Draft not found.")
|
|
|
|
draft = drafts[body.draft_id]
|
|
proposal_id = f"prop_{uuid.uuid4().hex[:12]}"
|
|
now = datetime.now(UTC).isoformat()
|
|
|
|
# Compute impact analysis using VersionManager.diff_ontologies and OntologyEngine
|
|
impact_analysis = {}
|
|
shacl_validation = {}
|
|
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
from ...change_management.ontology_version_manager import VersionManager
|
|
|
|
# Build ontology dicts from draft diff for comparison
|
|
base_ontology = {"uri": body.ontology_uri, "classes": [], "properties": []}
|
|
target_ontology = {
|
|
"uri": body.ontology_uri,
|
|
"classes": [{"uri": uri} for uri in draft.diff.added_classes],
|
|
"properties": [{"uri": uri} for uri in draft.diff.added_properties],
|
|
}
|
|
|
|
# Use VersionManager.diff_ontologies for structured diff
|
|
version_manager = VersionManager(
|
|
store=session.graph.store if hasattr(session.graph, "store") else None
|
|
)
|
|
diff_result = await asyncio.to_thread(
|
|
version_manager.diff_ontologies,
|
|
base_ontology,
|
|
target_ontology
|
|
)
|
|
|
|
# Use OntologyEngine for validation and SHACL
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Run validation if available
|
|
validation_results = {}
|
|
try:
|
|
val_res = await asyncio.to_thread(engine.validate, target_ontology)
|
|
validation_results = {
|
|
"valid": getattr(val_res, "valid", getattr(val_res, "is_valid", False)),
|
|
"consistent": getattr(val_res, "consistent", True),
|
|
"satisfiable": getattr(val_res, "satisfiable", True),
|
|
"errors": getattr(val_res, "errors", []),
|
|
"warnings": getattr(val_res, "warnings", [])
|
|
}
|
|
except Exception as val_exc:
|
|
logger.warning(f"Validation failed: {val_exc}")
|
|
validation_results = {"error": str(val_exc)}
|
|
|
|
# SHACL pre-validation using OntologyEngine.validate_graph
|
|
if engine.store:
|
|
try:
|
|
# Generate SHACL from target ontology
|
|
await asyncio.to_thread(
|
|
engine.to_shacl,
|
|
target_ontology,
|
|
format="turtle"
|
|
)
|
|
|
|
# Validate current graph data against new SHACL shapes
|
|
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
|
graph_data = {"nodes": all_nodes}
|
|
|
|
validation_report = await asyncio.to_thread(
|
|
engine.validate_graph,
|
|
graph_data,
|
|
ontology=target_ontology,
|
|
explain=True
|
|
)
|
|
|
|
shacl_validation = {
|
|
"status": "validated",
|
|
"conforms": getattr(validation_report, "conforms", True),
|
|
"violations": [
|
|
{
|
|
"message": v.message,
|
|
"severity": v.severity,
|
|
"focus_node": v.focus_node,
|
|
}
|
|
for v in getattr(validation_report, "violations", [])
|
|
]
|
|
}
|
|
except Exception as shacl_exc:
|
|
logger.warning(f"SHACL pre-validation failed: {shacl_exc}")
|
|
shacl_validation = {"status": "error", "error": str(shacl_exc)}
|
|
else:
|
|
shacl_validation = {"status": "skipped", "reason": "No store configured"}
|
|
|
|
impact_analysis = {
|
|
"diff": diff_result,
|
|
"validation_results": validation_results,
|
|
"class_adds": len(draft.diff.added_classes),
|
|
"class_removals": len(draft.diff.removed_classes),
|
|
"property_changes": len(draft.diff.added_properties) + len(draft.diff.removed_properties),
|
|
"restriction_changes": len(draft.diff.added_restrictions) + len(draft.diff.removed_restrictions),
|
|
}
|
|
|
|
except Exception as exc:
|
|
logger.warning(f"Impact analysis failed: {exc}")
|
|
impact_analysis = {"error": str(exc)}
|
|
shacl_validation = {"status": "error", "error": str(exc)}
|
|
|
|
proposal = ProposalResponse(
|
|
proposal_id=proposal_id,
|
|
draft_id=body.draft_id,
|
|
ontology_uri=body.ontology_uri,
|
|
summary=body.summary,
|
|
author=draft.author,
|
|
reviewer=body.reviewer,
|
|
state="proposed",
|
|
impact_analysis=impact_analysis,
|
|
shacl_validation=shacl_validation,
|
|
created_at=now,
|
|
updated_at=now,
|
|
comments=[],
|
|
)
|
|
proposals[proposal_id] = proposal
|
|
return proposal
|
|
|
|
|
|
@router.get("/proposals", response_model=List[ProposalResponse])
|
|
async def list_proposals(
|
|
request: Request,
|
|
ontology_uri: Optional[str] = Query(None),
|
|
state: Optional[str] = Query(None),
|
|
):
|
|
"""List proposals with optional filters."""
|
|
proposals = _get_proposals(request)
|
|
result = list(proposals.values())
|
|
|
|
if ontology_uri:
|
|
result = [p for p in result if p.ontology_uri == ontology_uri]
|
|
if state:
|
|
result = [p for p in result if p.state == state]
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/proposals/{proposal_id}", response_model=ProposalResponse)
|
|
async def get_proposal(
|
|
proposal_id: str,
|
|
request: Request,
|
|
):
|
|
"""Get proposal detail."""
|
|
proposals = _get_proposals(request)
|
|
if proposal_id not in proposals:
|
|
raise HTTPException(status_code=404, detail="Proposal not found.")
|
|
return proposals[proposal_id]
|
|
|
|
|
|
@router.post("/proposals/{proposal_id}/approve")
|
|
async def approve_proposal(
|
|
proposal_id: str,
|
|
request: Request,
|
|
):
|
|
"""Approve a proposal."""
|
|
proposals = _get_proposals(request)
|
|
if proposal_id not in proposals:
|
|
raise HTTPException(status_code=404, detail="Proposal not found.")
|
|
proposal = proposals[proposal_id]
|
|
proposal.state = "approved"
|
|
proposal.updated_at = datetime.now(UTC).isoformat()
|
|
return {"status": "approved", "proposal_id": proposal_id}
|
|
|
|
|
|
@router.post("/proposals/{proposal_id}/reject")
|
|
async def reject_proposal(
|
|
proposal_id: str,
|
|
request: Request,
|
|
):
|
|
"""Reject a proposal (can return to draft)."""
|
|
proposals = _get_proposals(request)
|
|
if proposal_id not in proposals:
|
|
raise HTTPException(status_code=404, detail="Proposal not found.")
|
|
proposal = proposals[proposal_id]
|
|
proposal.state = "rejected"
|
|
proposal.updated_at = datetime.now(UTC).isoformat()
|
|
return {"status": "rejected", "proposal_id": proposal_id}
|
|
|
|
|
|
@router.post("/proposals/{proposal_id}/publish")
|
|
async def publish_proposal(
|
|
proposal_id: str,
|
|
request: Request,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Publish an approved proposal using VersionManager.create_version()."""
|
|
proposals = _get_proposals(request)
|
|
if proposal_id not in proposals:
|
|
raise HTTPException(status_code=404, detail="Proposal not found.")
|
|
proposal = proposals[proposal_id]
|
|
|
|
if proposal.state != "approved":
|
|
raise HTTPException(status_code=400, detail="Only approved proposals can be published.")
|
|
|
|
drafts = _get_drafts(request)
|
|
if proposal.draft_id not in drafts:
|
|
raise HTTPException(status_code=404, detail="Draft not found.")
|
|
draft = drafts[proposal.draft_id]
|
|
|
|
# Build ontology dict for version creation
|
|
ontology_dict = {
|
|
"uri": proposal.ontology_uri,
|
|
"classes": [{"uri": uri} for uri in draft.diff.added_classes],
|
|
"properties": [{"uri": uri} for uri in draft.diff.added_properties],
|
|
"diff": draft.diff.model_dump(),
|
|
}
|
|
|
|
# Create version record using VersionManager
|
|
try:
|
|
from ...change_management.ontology_version_manager import VersionManager
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize VersionManager with proper config
|
|
version_manager = VersionManager(
|
|
store=session.graph.store if hasattr(session.graph, "store") else None
|
|
)
|
|
|
|
# Generate version string based on existing versions
|
|
versions = _get_versions(request)
|
|
existing_versions = versions.get(proposal.ontology_uri, [])
|
|
version_num = len(existing_versions) + 1
|
|
version_str = f"1.{version_num}.0"
|
|
|
|
version_manager.create_version(
|
|
version=version_str,
|
|
ontology=ontology_dict,
|
|
changes=[proposal.summary],
|
|
)
|
|
|
|
# Store version in app state
|
|
if proposal.ontology_uri not in versions:
|
|
versions[proposal.ontology_uri] = []
|
|
|
|
versions[proposal.ontology_uri].append(
|
|
VersionEntry(
|
|
version_id=version_str,
|
|
ontology_uri=proposal.ontology_uri,
|
|
state="published",
|
|
author=proposal.author,
|
|
date=datetime.now(UTC).isoformat(),
|
|
diff_summary=draft.diff.model_dump(),
|
|
)
|
|
)
|
|
|
|
logger.info(f"Created version {version_str} for ontology {proposal.ontology_uri}")
|
|
|
|
except Exception as exc:
|
|
logger.warning(f"VersionManager.create_version failed: {exc}")
|
|
raise HTTPException(status_code=500, detail=f"Version creation failed: {exc}") from exc
|
|
|
|
# Apply additions only after version creation succeeds so failed publishes do not
|
|
# leave a partially mutated live graph.
|
|
nodes_added = 0
|
|
edges_added = 0
|
|
|
|
class_nodes = []
|
|
for class_uri in draft.diff.added_classes:
|
|
label = class_uri.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
|
|
class_nodes.append({
|
|
"id": class_uri,
|
|
"type": "owl:Class",
|
|
"content": label,
|
|
"properties": {"rdfs:label": label},
|
|
})
|
|
if class_nodes:
|
|
nodes_added += await asyncio.to_thread(session.add_nodes, class_nodes)
|
|
|
|
property_nodes = []
|
|
for prop_uri in draft.diff.added_properties:
|
|
label = prop_uri.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
|
|
property_nodes.append({
|
|
"id": prop_uri,
|
|
"type": "owl:ObjectProperty",
|
|
"content": label,
|
|
"properties": {"rdfs:label": label},
|
|
})
|
|
if property_nodes:
|
|
nodes_added += await asyncio.to_thread(session.add_nodes, property_nodes)
|
|
|
|
proposal.state = "published"
|
|
proposal.updated_at = datetime.now(UTC).isoformat()
|
|
|
|
return {
|
|
"status": "published",
|
|
"proposal_id": proposal_id,
|
|
"version": version_str,
|
|
"nodes_added": nodes_added,
|
|
"edges_added": edges_added,
|
|
}
|
|
|
|
|
|
@router.post("/proposals/{proposal_id}/comment")
|
|
async def add_comment(
|
|
proposal_id: str,
|
|
body: CommentRequest,
|
|
request: Request,
|
|
):
|
|
"""Add inline comment to a proposal."""
|
|
proposals = _get_proposals(request)
|
|
if proposal_id not in proposals:
|
|
raise HTTPException(status_code=404, detail="Proposal not found.")
|
|
proposal = proposals[proposal_id]
|
|
comment = {
|
|
"id": f"comment_{uuid.uuid4().hex[:8]}",
|
|
"element_uri": body.element_uri,
|
|
"text": body.text,
|
|
"author": body.author,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
proposal.comments.append(comment)
|
|
proposal.updated_at = datetime.now(UTC).isoformat()
|
|
return {"status": "commented", "comment_id": comment["id"]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Version endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/versions/{ontology_uri:path}", response_model=List[VersionEntry])
|
|
async def list_versions(
|
|
ontology_uri: str,
|
|
request: Request,
|
|
):
|
|
"""List version history for an ontology."""
|
|
versions = _get_versions(request)
|
|
return versions.get(ontology_uri, [])
|
|
|
|
|
|
@router.post("/versions/{ontology_uri:path}/compare", response_model=VersionCompareResponse)
|
|
async def compare_versions(
|
|
ontology_uri: str,
|
|
body: VersionCompareRequest,
|
|
request: Request,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Compare two ontology versions using VersionManager.compare_versions()."""
|
|
try:
|
|
from ...change_management.ontology_version_manager import VersionManager
|
|
|
|
# Initialize VersionManager with session's graph store
|
|
version_manager = VersionManager(
|
|
store=session.graph.store if hasattr(session.graph, "store") else None
|
|
)
|
|
|
|
# Get version data from app state
|
|
versions = _get_versions(request)
|
|
ontology_versions = versions.get(ontology_uri, [])
|
|
|
|
# Find version records. Older in-memory state may contain plain dicts, while
|
|
# new publishes store VersionEntry models.
|
|
v1_record = next((v for v in ontology_versions if _version_field(v, "version_id") == body.version1), None)
|
|
v2_record = next((v for v in ontology_versions if _version_field(v, "version_id") == body.version2), None)
|
|
|
|
if not v1_record or not v2_record:
|
|
raise HTTPException(status_code=404, detail="One or both versions not found.")
|
|
|
|
# Build ontology dicts from version records
|
|
v1_dict = {
|
|
"uri": ontology_uri,
|
|
"version": body.version1,
|
|
"classes": [],
|
|
"properties": [],
|
|
"diff": _version_field(v1_record, "diff_summary", {}),
|
|
}
|
|
v2_dict = {
|
|
"uri": ontology_uri,
|
|
"version": body.version2,
|
|
"classes": [],
|
|
"properties": [],
|
|
"diff": _version_field(v2_record, "diff_summary", {}),
|
|
}
|
|
|
|
# Use VersionManager.diff_ontologies for structured comparison
|
|
diff_result = await asyncio.to_thread(
|
|
version_manager.diff_ontologies,
|
|
v1_dict,
|
|
v2_dict
|
|
)
|
|
|
|
metadata_changes = {
|
|
"version_id": {"from": body.version1, "to": body.version2},
|
|
"author": {
|
|
"from": _version_field(v1_record, "author"),
|
|
"to": _version_field(v2_record, "author"),
|
|
},
|
|
"date": {
|
|
"from": _version_field(v1_record, "date"),
|
|
"to": _version_field(v2_record, "date"),
|
|
},
|
|
"state": {
|
|
"from": _version_field(v1_record, "state"),
|
|
"to": _version_field(v2_record, "state"),
|
|
},
|
|
}
|
|
|
|
return VersionCompareResponse(
|
|
version1=body.version1,
|
|
version2=body.version2,
|
|
metadata_changes=metadata_changes,
|
|
class_changes={
|
|
"added": diff_result.get("added_classes", []),
|
|
"removed": diff_result.get("removed_classes", []),
|
|
"changed": diff_result.get("changed_classes", []),
|
|
},
|
|
property_changes={
|
|
"added": diff_result.get("added_properties", []),
|
|
"removed": diff_result.get("removed_properties", []),
|
|
"changed": diff_result.get("changed_properties", []),
|
|
},
|
|
restriction_changes={
|
|
"added": diff_result.get("added_axioms", []),
|
|
"removed": diff_result.get("removed_axioms", []),
|
|
"changed": diff_result.get("changed_axioms", []),
|
|
},
|
|
axiom_changes={
|
|
"added": diff_result.get("added_axioms", []),
|
|
"removed": diff_result.get("removed_axioms", []),
|
|
"changed": diff_result.get("changed_axioms", []),
|
|
},
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.error(f"Version comparison failed: {exc}")
|
|
raise HTTPException(status_code=500, detail=f"Version comparison failed: {exc}") from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alignment endpoints using OntologyEngine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/alignments", response_model=AlignmentResponse)
|
|
async def create_alignment(
|
|
body: AlignmentRequest,
|
|
request: Request,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Create an alignment between two ontology entities using OntologyEngine.create_alignment."""
|
|
response = AlignmentResponse(
|
|
source=body.source_uri,
|
|
predicate=body.predicate,
|
|
target=body.target_uri,
|
|
)
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Use OntologyEngine.create_alignment
|
|
await asyncio.to_thread(
|
|
engine.create_alignment,
|
|
body.source_uri,
|
|
body.target_uri,
|
|
body.predicate
|
|
)
|
|
except Exception as exc:
|
|
logger.info("OntologyEngine alignment store unavailable; using route-level store: %s", exc)
|
|
|
|
alignment_store = _get_alignment_store(request)
|
|
alignment_store[_alignment_key(response.source, response.predicate, response.target)] = response
|
|
return response
|
|
|
|
|
|
@router.get("/alignments/{entity_uri:path}", response_model=List[AlignmentResponse])
|
|
async def get_alignments(
|
|
entity_uri: str,
|
|
request: Request,
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""Get all alignments for an entity using OntologyEngine.get_alignments."""
|
|
route_alignments = [
|
|
alignment
|
|
for alignment in _get_alignment_store(request).values()
|
|
if alignment.source == entity_uri or alignment.target == entity_uri
|
|
]
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Use OntologyEngine.get_alignments
|
|
alignments = await asyncio.to_thread(engine.get_alignments, entity_uri)
|
|
|
|
engine_alignments = [
|
|
alignment
|
|
for alignment in (_coerce_alignment(align) for align in alignments)
|
|
if alignment is not None
|
|
]
|
|
merged = {
|
|
_alignment_key(alignment.source, alignment.predicate, alignment.target): alignment
|
|
for alignment in route_alignments + engine_alignments
|
|
}
|
|
return list(merged.values())
|
|
except Exception as exc:
|
|
logger.info("OntologyEngine alignment lookup unavailable; using route-level store: %s", exc)
|
|
return route_alignments
|
|
|
|
|
|
@router.get("/alignments", response_model=List[AlignmentResponse])
|
|
async def list_alignments(
|
|
request: Request,
|
|
ontology_uri: Optional[str] = Query(None),
|
|
session: GraphSession = Depends(get_session),
|
|
):
|
|
"""List all alignments, optionally filtered by ontology URI using OntologyEngine.list_alignments."""
|
|
route_alignments = list(_get_alignment_store(request).values())
|
|
if ontology_uri:
|
|
route_alignments = [
|
|
alignment for alignment in route_alignments
|
|
if ontology_uri in alignment.source or ontology_uri in alignment.target
|
|
]
|
|
try:
|
|
from ...ontology import OntologyEngine
|
|
|
|
# Initialize OntologyEngine with session's graph store
|
|
engine_config = {"store": session.graph.store if hasattr(session.graph, "store") else None}
|
|
engine = OntologyEngine(**engine_config)
|
|
|
|
# Use OntologyEngine.list_alignments
|
|
alignments = await asyncio.to_thread(engine.list_alignments, ontology_uri=ontology_uri)
|
|
|
|
engine_alignments = [
|
|
alignment
|
|
for alignment in (_coerce_alignment(align) for align in alignments)
|
|
if alignment is not None
|
|
]
|
|
merged = {
|
|
_alignment_key(alignment.source, alignment.predicate, alignment.target): alignment
|
|
for alignment in route_alignments + engine_alignments
|
|
}
|
|
return list(merged.values())
|
|
except Exception as exc:
|
|
logger.info("OntologyEngine alignment listing unavailable; using route-level store: %s", exc)
|
|
return route_alignments
|