feat(#319): SKOS Vocabulary Module — namespace helpers, store helpers, OntologyEngine APIs, tests, docs

Extends the existing ontology and triplet-store stack with first-class
SKOS support without adding any new top-level packages.

### semantica/ontology/namespace_manager.py
- `get_skos_uri(local_name)` — build full skos:core# URI from local name
- `build_concept_scheme_uri(name)` — slug a human name into a stable
  ConceptScheme URI anchored at the configured base URI

### semantica/triplet_store/triplet_store.py
- `add_skos_concept(concept_uri, scheme_uri, pref_label, ...)` — asserts
  ConceptScheme + Concept triples, prefLabel, altLabel, broader, narrower,
  related, definition, notation via existing `add_triplets()` API
- `get_skos_concepts(scheme_uri=None)` — SPARQL SELECT via `execute_query()`,
  collapses multi-valued bindings into concept dicts

### semantica/ontology/engine.py
- `list_vocabularies()` — list all skos:ConceptScheme instances
- `list_concepts(scheme_uri)` — list concepts in a scheme with alt labels
- `search_concepts(query, scheme_uri=None)` — case-insensitive substring
  search over prefLabel + altLabel; sanitises user input against SPARQL injection

### tests
- `TestSKOSOntologyEngine` (14 tests) in test_ontology_comprehensive.py
- `TestSKOSTripletStore` (6 tests) in test_triplet_store.py
- All 1162 existing + new tests pass, 0 failures

### docs/reference/ontology.md
- New "SKOS Vocabulary Management" section: data-model table, import
  examples (add_skos_concept + rdflib bulk), list/search API, NamespaceManager helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
KaifAhmad1
2026-03-28 13:59:23 +05:30
co-authored by Claude Sonnet 4.6
parent 6651386074
commit 71d037f581
6 changed files with 850 additions and 0 deletions
+108
View File
@@ -335,6 +335,114 @@ else:
---
## SKOS Vocabulary Management
Semantica supports [SKOS (Simple Knowledge Organization System)](https://www.w3.org/TR/skos-reference/) vocabularies as first-class semantic assets. SKOS triples are stored in the existing RDF triplet store and queried through the `OntologyEngine` — no additional packages are required.
### Concepts and data model
| SKOS element | RDF type / predicate |
|---|---|
| ConceptScheme | `skos:ConceptScheme` |
| Concept | `skos:Concept` |
| Preferred label | `skos:prefLabel` |
| Alternative label | `skos:altLabel` |
| Broader concept | `skos:broader` |
| Narrower concept | `skos:narrower` |
| Related concept | `skos:related` |
| Human definition | `skos:definition` |
| Notation / code | `skos:notation` |
### Importing a SKOS vocabulary
Use `TripletStore.add_skos_concept()` to load individual concepts. The method automatically asserts the parent `skos:ConceptScheme` triple the first time any concept for that scheme is added.
```python
from semantica.triplet_store import TripletStore
store = TripletStore(backend="blazegraph", endpoint="http://localhost:9999/blazegraph")
SCHEME = "https://vocab.example.org/colours"
store.add_skos_concept(
concept_uri="https://vocab.example.org/colours/red",
scheme_uri=SCHEME,
pref_label="Red",
alt_labels=["Crimson", "Rouge"],
broader=["https://vocab.example.org/colours/warm"],
definition="The colour at the long-wavelength end of the visible spectrum.",
notation="RED",
)
store.add_skos_concept(
concept_uri="https://vocab.example.org/colours/blue",
scheme_uri=SCHEME,
pref_label="Blue",
alt_labels=["Azure", "Cerulean"],
)
```
For bulk ingestion of an existing SKOS/Turtle file use `TripletStore.add_triplets()` after parsing the file with [rdflib](https://rdflib.readthedocs.io/):
```python
import rdflib
from semantica.semantic_extract.triplet_extractor import Triplet
g = rdflib.Graph()
g.parse("my_vocabulary.ttl", format="turtle")
triplets = [
Triplet(subject=str(s), predicate=str(p), object=str(o))
for s, p, o in g
]
store.add_triplets(triplets)
```
### Listing and searching concepts
Once a vocabulary is loaded, use `OntologyEngine` to browse and search it:
```python
from semantica.ontology import OntologyEngine
engine = OntologyEngine(store=store)
# 1. List all ConceptSchemes in the store
vocabularies = engine.list_vocabularies()
# [{"uri": "https://vocab.example.org/colours", "label": "Colours"}, ...]
# 2. List every concept in a specific scheme
concepts = engine.list_concepts("https://vocab.example.org/colours")
# [{"uri": "...", "pref_label": "Red", "alt_labels": ["Crimson", "Rouge"]}, ...]
# 3. Case-insensitive substring search across prefLabel and altLabel
results = engine.search_concepts("crimson")
# [{"uri": "https://vocab.example.org/colours/red", "label": "Crimson"}]
# 4. Restrict search to one scheme
results = engine.search_concepts("azure", scheme_uri="https://vocab.example.org/colours")
```
### Building SKOS URIs with NamespaceManager
`NamespaceManager` provides helpers for constructing well-formed SKOS IRIs:
```python
from semantica.ontology import NamespaceManager
nm = NamespaceManager(base_uri="https://vocab.example.org/")
# Full SKOS predicate URI
nm.get_skos_uri("prefLabel")
# "http://www.w3.org/2004/02/skos/core#prefLabel"
# Slug-based ConceptScheme URI anchored at the base
nm.build_concept_scheme_uri("ISO 3166 Countries")
# "https://vocab.example.org/vocab/iso-3166-countries"
```
---
## Best Practices
1. **Reuse Standard Ontologies**: Don't reinvent `Person` or `Organization`; import FOAF or Schema.org using `ReuseManager`.
+188
View File
@@ -385,6 +385,194 @@ class OntologyEngine:
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
raise
# ── SKOS Vocabulary Management ────────────────────────────────────────────
_SKOS = "http://www.w3.org/2004/02/skos/core#"
_RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
def list_vocabularies(self, **options) -> List[Dict[str, Any]]:
"""
List all SKOS ConceptSchemes stored in the triplet store.
Returns:
List of dicts with keys ``uri`` and ``label`` (may be empty string
when no ``skos:prefLabel`` is present).
Raises:
ProcessingError: If no store is configured or the query fails.
"""
if not self.store:
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
query = f"""
SELECT DISTINCT ?scheme ?label WHERE {{
?scheme <{RDF_TYPE}> <{SKOS}ConceptScheme> .
OPTIONAL {{ ?scheme <{SKOS}prefLabel> ?label }}
}}
"""
tracking_id = self.progress.start_tracking(
module="ontology", submodule="OntologyEngine", message="Listing SKOS vocabularies"
)
try:
result = self.store.execute_query(query, **options)
vocabs = []
if hasattr(result, "bindings"):
seen: set = set()
for b in result.bindings:
def _v(key):
val = b.get(key)
return (val.get("value") if isinstance(val, dict) else val) if val else None
uri = _v("scheme")
if uri and uri not in seen:
seen.add(uri)
vocabs.append({"uri": uri, "label": _v("label") or ""})
self.progress.stop_tracking(tracking_id, status="completed",
message=f"Found {len(vocabs)} vocabularies")
return vocabs
except Exception as e:
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
raise ProcessingError(f"list_vocabularies failed: {e}")
def list_concepts(self, scheme_uri: str, **options) -> List[Dict[str, Any]]:
"""
List all SKOS concepts that belong to the given ConceptScheme.
Args:
scheme_uri: Full URI of the ``skos:ConceptScheme`` to inspect.
Returns:
List of dicts with keys ``uri``, ``pref_label``, and
``alt_labels`` (list, may be empty).
Raises:
ProcessingError: If no store is configured or the query fails.
"""
if not self.store:
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
safe_scheme = self._sanitize_uri(scheme_uri)
query = f"""
SELECT DISTINCT ?concept ?prefLabel ?altLabel WHERE {{
?concept <{RDF_TYPE}> <{SKOS}Concept> .
?concept <{SKOS}inScheme> <{safe_scheme}> .
OPTIONAL {{ ?concept <{SKOS}prefLabel> ?prefLabel }}
OPTIONAL {{ ?concept <{SKOS}altLabel> ?altLabel }}
}}
"""
tracking_id = self.progress.start_tracking(
module="ontology", submodule="OntologyEngine",
message=f"Listing concepts in {scheme_uri}"
)
try:
result = self.store.execute_query(query, **options)
concepts: Dict[str, Dict[str, Any]] = {}
if hasattr(result, "bindings"):
for b in result.bindings:
def _v(key):
val = b.get(key)
return (val.get("value") if isinstance(val, dict) else val) if val else None
uri = _v("concept")
if not uri:
continue
if uri not in concepts:
concepts[uri] = {"uri": uri, "pref_label": _v("prefLabel") or "", "alt_labels": []}
if not concepts[uri]["pref_label"] and _v("prefLabel"):
concepts[uri]["pref_label"] = _v("prefLabel")
lbl = _v("altLabel")
if lbl and lbl not in concepts[uri]["alt_labels"]:
concepts[uri]["alt_labels"].append(lbl)
self.progress.stop_tracking(tracking_id, status="completed",
message=f"Found {len(concepts)} concepts")
return list(concepts.values())
except Exception as e:
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
raise ProcessingError(f"list_concepts failed: {e}")
def search_concepts(
self,
query: str,
scheme_uri: Optional[str] = None,
**options,
) -> List[Dict[str, Any]]:
"""
Search SKOS concepts by matching ``skos:prefLabel`` or ``skos:altLabel``.
The search is case-insensitive substring matching performed at the
SPARQL level via ``CONTAINS(LCASE(…))``.
Args:
query: Substring to search for.
scheme_uri: When given, restrict results to this ConceptScheme.
Returns:
List of dicts with keys ``uri`` and ``label`` (the matching label).
Raises:
ProcessingError: If no store is configured or the query fails.
"""
if not self.store:
raise ProcessingError("TripletStore instance not configured in OntologyEngine.")
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
# Sanitize user query for embedding in a SPARQL string literal
safe_query = (
query
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", " ")
.replace("\r", " ")
)
scheme_filter = ""
if scheme_uri:
safe_scheme = self._sanitize_uri(scheme_uri)
scheme_filter = f"?concept <{SKOS}inScheme> <{safe_scheme}> ."
sparql = f"""
SELECT DISTINCT ?concept ?label WHERE {{
?concept <{RDF_TYPE}> <{SKOS}Concept> .
{scheme_filter}
{{
?concept <{SKOS}prefLabel> ?label
}} UNION {{
?concept <{SKOS}altLabel> ?label
}}
FILTER(CONTAINS(LCASE(STR(?label)), LCASE("{safe_query}")))
}}
"""
tracking_id = self.progress.start_tracking(
module="ontology", submodule="OntologyEngine",
message=f"Searching SKOS concepts: '{query}'"
)
try:
result = self.store.execute_query(sparql, **options)
matches = []
seen: set = set()
if hasattr(result, "bindings"):
for b in result.bindings:
def _v(key):
val = b.get(key)
return (val.get("value") if isinstance(val, dict) else val) if val else None
uri = _v("concept")
lbl = _v("label")
if uri and uri not in seen:
seen.add(uri)
matches.append({"uri": uri, "label": lbl or ""})
self.progress.stop_tracking(tracking_id, status="completed",
message=f"Found {len(matches)} matches")
return matches
except Exception as e:
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
raise ProcessingError(f"search_concepts failed: {e}")
# ── Ontology Evaluation / Validation ─────────────────────────────────────
def evaluate(self, ontology: Dict[str, Any], **options):
+29
View File
@@ -207,6 +207,35 @@ class NamespaceManager:
"""
return dict(self.namespaces)
def get_skos_uri(self, local_name: str) -> str:
"""
Build a full SKOS URI from a local name.
Args:
local_name: SKOS local term (e.g. ``"Concept"``, ``"prefLabel"``)
Returns:
Full SKOS URI string
"""
skos_ns = self.namespaces["skos"]
return f"{skos_ns}{local_name}"
def build_concept_scheme_uri(self, name: str) -> str:
"""
Build a ConceptScheme URI anchored at the current base URI.
The scheme name is slugified (spaces → hyphens, lower-cased) so that
``"My Vocabulary"`` becomes ``<base>/vocab/my-vocabulary>``.
Args:
name: Human-readable vocabulary name
Returns:
ConceptScheme URI string
"""
slug = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
return urljoin(self.get_base_uri(), f"vocab/{slug}")
def get_alignment_predicates(self) -> Dict[str, str]:
"""
Get standard alignment predicates for ontology mapping.
+151
View File
@@ -422,6 +422,157 @@ class TripletStore:
return True
# ── SKOS helpers ─────────────────────────────────────────────────────────
_SKOS = "http://www.w3.org/2004/02/skos/core#"
_RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
def add_skos_concept(
self,
concept_uri: str,
scheme_uri: str,
pref_label: str,
alt_labels: Optional[List[str]] = None,
broader: Optional[List[str]] = None,
narrower: Optional[List[str]] = None,
related: Optional[List[str]] = None,
definition: Optional[str] = None,
notation: Optional[str] = None,
**options,
) -> Dict[str, Any]:
"""
Add a SKOS concept (and its scheme if not already present) to the store.
Core triples added:
* ``concept_uri rdf:type skos:Concept``
* ``concept_uri skos:inScheme scheme_uri``
* ``concept_uri skos:prefLabel pref_label``
* ``scheme_uri rdf:type skos:ConceptScheme`` (auto-created)
* Optional: altLabel, broader, narrower, related, definition, notation
Args:
concept_uri: Full URI for the concept.
scheme_uri: Full URI for the parent ConceptScheme.
pref_label: Preferred label string.
alt_labels: Optional list of alternative label strings.
broader: Optional list of broader concept URIs.
narrower: Optional list of narrower concept URIs.
related: Optional list of related concept URIs.
definition: Optional human-readable definition string.
notation: Optional notation / code string.
**options: Forwarded to :meth:`add_triplets`.
Returns:
:meth:`add_triplets` status dict.
"""
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
triplets: List[Triplet] = [
# Scheme declaration
Triplet(scheme_uri, RDF_TYPE, f"{SKOS}ConceptScheme"),
# Concept core
Triplet(concept_uri, RDF_TYPE, f"{SKOS}Concept"),
Triplet(concept_uri, f"{SKOS}inScheme", scheme_uri),
Triplet(concept_uri, f"{SKOS}prefLabel", pref_label),
]
for lbl in (alt_labels or []):
triplets.append(Triplet(concept_uri, f"{SKOS}altLabel", lbl))
for uri in (broader or []):
triplets.append(Triplet(concept_uri, f"{SKOS}broader", uri))
for uri in (narrower or []):
triplets.append(Triplet(concept_uri, f"{SKOS}narrower", uri))
for uri in (related or []):
triplets.append(Triplet(concept_uri, f"{SKOS}related", uri))
if definition:
triplets.append(Triplet(concept_uri, f"{SKOS}definition", definition))
if notation:
triplets.append(Triplet(concept_uri, f"{SKOS}notation", notation))
return self.add_triplets(triplets, **options)
def get_skos_concepts(
self, scheme_uri: Optional[str] = None, **options
) -> List[Dict[str, Any]]:
"""
Retrieve SKOS concepts from the store as plain dicts.
Each returned dict has at minimum ``uri`` and ``pref_label``; optional
keys ``alt_labels``, ``broader``, ``narrower``, and ``related`` are
populated when available.
Args:
scheme_uri: When given, only concepts ``skos:inScheme`` this URI
are returned. When omitted all concepts are returned.
**options: Forwarded to :meth:`execute_query`.
Returns:
List of concept dicts.
"""
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
scheme_filter = (
f"?concept <{SKOS}inScheme> <{self.query_engine._sanitize_uri(scheme_uri)}> ."
if scheme_uri
else ""
)
query = f"""
SELECT DISTINCT ?concept ?prefLabel ?altLabel ?broader ?narrower ?related
WHERE {{
?concept <{RDF_TYPE}> <{SKOS}Concept> .
{scheme_filter}
OPTIONAL {{ ?concept <{SKOS}prefLabel> ?prefLabel }}
OPTIONAL {{ ?concept <{SKOS}altLabel> ?altLabel }}
OPTIONAL {{ ?concept <{SKOS}broader> ?broader }}
OPTIONAL {{ ?concept <{SKOS}narrower> ?narrower }}
OPTIONAL {{ ?concept <{SKOS}related> ?related }}
}}
"""
try:
result = self.execute_query(query, **options)
except Exception as e:
self.logger.error(f"get_skos_concepts query failed: {e}")
raise ProcessingError(f"Failed to retrieve SKOS concepts: {e}")
# Collapse multi-valued properties per concept URI
concepts: Dict[str, Dict[str, Any]] = {}
for b in result.bindings:
def _val(key: str) -> Optional[str]:
v = b.get(key)
return (v.get("value") if isinstance(v, dict) else v) if v else None
uri = _val("concept")
if not uri:
continue
if uri not in concepts:
concepts[uri] = {
"uri": uri,
"pref_label": _val("prefLabel") or "",
"alt_labels": [],
"broader": [],
"narrower": [],
"related": [],
}
entry = concepts[uri]
if not entry["pref_label"] and _val("prefLabel"):
entry["pref_label"] = _val("prefLabel")
for multi_key, sparql_key in [
("alt_labels", "altLabel"),
("broader", "broader"),
("narrower", "narrower"),
("related", "related"),
]:
v = _val(sparql_key)
if v and v not in entry[multi_key]:
entry[multi_key].append(v)
return list(concepts.values())
def get_stats(self) -> Dict[str, Any]:
"""Get store statistics."""
if hasattr(self._store_backend, "get_stats"):
@@ -431,5 +431,199 @@ class TestSHACLGeneration(unittest.TestCase):
self.assertEqual(gen.quality_tier, "standard")
class TestSKOSOntologyEngine(unittest.TestCase):
"""Tests for SKOS vocabulary management APIs in OntologyEngine."""
def setUp(self):
self.mock_logger = MagicMock()
self.mock_tracker = MagicMock()
self.mock_tracker.start_tracking.return_value = "track_id"
patchers = [
patch('semantica.ontology.engine.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.engine.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.ontology_generator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.ontology_generator.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.class_inferrer.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.class_inferrer.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.property_generator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.property_generator.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.owl_generator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.owl_generator.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.ontology_evaluator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.ontology_evaluator.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.ontology.ontology_validator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.llm_generator.get_logger', return_value=self.mock_logger),
patch('semantica.ontology.llm_generator.get_progress_tracker', return_value=self.mock_tracker),
patch('semantica.change_management.ontology_version_manager.get_logger', return_value=self.mock_logger),
patch('semantica.change_management.ontology_version_manager.get_progress_tracker', return_value=self.mock_tracker),
]
self.patchers = patchers
for p in self.patchers:
p.start()
# Mock store with a controllable execute_query
self.mock_store = MagicMock()
from semantica.ontology.engine import OntologyEngine
self.engine = OntologyEngine(store=self.mock_store)
def tearDown(self):
for p in self.patchers:
p.stop()
def _make_result(self, bindings):
"""Build a fake QueryResult-like object."""
result = MagicMock()
result.bindings = bindings
return result
# --- NamespaceManager SKOS helpers ---
def test_get_skos_uri(self):
from semantica.ontology.namespace_manager import NamespaceManager
nm = NamespaceManager()
self.assertEqual(
nm.get_skos_uri("Concept"),
"http://www.w3.org/2004/02/skos/core#Concept",
)
self.assertEqual(
nm.get_skos_uri("prefLabel"),
"http://www.w3.org/2004/02/skos/core#prefLabel",
)
def test_build_concept_scheme_uri(self):
from semantica.ontology.namespace_manager import NamespaceManager
nm = NamespaceManager(base_uri="https://example.org/onto/")
uri = nm.build_concept_scheme_uri("My Vocabulary")
self.assertIn("my-vocabulary", uri)
self.assertTrue(uri.startswith("https://example.org/onto/"))
def test_build_concept_scheme_uri_special_chars(self):
from semantica.ontology.namespace_manager import NamespaceManager
nm = NamespaceManager()
uri = nm.build_concept_scheme_uri("ISO 3166 Countries")
self.assertIn("iso-3166-countries", uri)
# --- list_vocabularies ---
def test_list_vocabularies_returns_schemes(self):
self.mock_store.execute_query.return_value = self._make_result([
{"scheme": {"value": "http://example.org/vocab/colours"},
"label": {"value": "Colours"}},
{"scheme": {"value": "http://example.org/vocab/sizes"},
"label": None},
])
vocabs = self.engine.list_vocabularies()
self.assertEqual(len(vocabs), 2)
uris = [v["uri"] for v in vocabs]
self.assertIn("http://example.org/vocab/colours", uris)
self.assertIn("http://example.org/vocab/sizes", uris)
colours = next(v for v in vocabs if "colours" in v["uri"])
self.assertEqual(colours["label"], "Colours")
def test_list_vocabularies_deduplicates(self):
# Same scheme URI appearing twice (multi-valued label rows)
self.mock_store.execute_query.return_value = self._make_result([
{"scheme": {"value": "http://example.org/vocab/colours"},
"label": {"value": "Colours"}},
{"scheme": {"value": "http://example.org/vocab/colours"},
"label": {"value": "Colors"}},
])
vocabs = self.engine.list_vocabularies()
self.assertEqual(len(vocabs), 1)
def test_list_vocabularies_no_store_raises(self):
from semantica.utils.exceptions import ProcessingError
from semantica.ontology.engine import OntologyEngine
engine_no_store = OntologyEngine()
with self.assertRaises(ProcessingError):
engine_no_store.list_vocabularies()
# --- list_concepts ---
def test_list_concepts_returns_concepts(self):
self.mock_store.execute_query.return_value = self._make_result([
{"concept": {"value": "http://example.org/concept/red"},
"prefLabel": {"value": "Red"},
"altLabel": {"value": "Crimson"}},
{"concept": {"value": "http://example.org/concept/red"},
"prefLabel": {"value": "Red"},
"altLabel": {"value": "Rouge"}},
{"concept": {"value": "http://example.org/concept/blue"},
"prefLabel": {"value": "Blue"},
"altLabel": None},
])
concepts = self.engine.list_concepts("http://example.org/vocab/colours")
self.assertEqual(len(concepts), 2)
red = next(c for c in concepts if "red" in c["uri"])
self.assertEqual(red["pref_label"], "Red")
self.assertIn("Crimson", red["alt_labels"])
self.assertIn("Rouge", red["alt_labels"])
blue = next(c for c in concepts if "blue" in c["uri"])
self.assertEqual(blue["alt_labels"], [])
def test_list_concepts_no_store_raises(self):
from semantica.utils.exceptions import ProcessingError
from semantica.ontology.engine import OntologyEngine
engine_no_store = OntologyEngine()
with self.assertRaises(ProcessingError):
engine_no_store.list_concepts("http://example.org/vocab/colours")
# --- search_concepts ---
def test_search_concepts_returns_matches(self):
self.mock_store.execute_query.return_value = self._make_result([
{"concept": {"value": "http://example.org/concept/red"},
"label": {"value": "Red"}},
{"concept": {"value": "http://example.org/concept/infrared"},
"label": {"value": "Infrared"}},
])
results = self.engine.search_concepts("red")
self.assertEqual(len(results), 2)
uris = [r["uri"] for r in results]
self.assertIn("http://example.org/concept/red", uris)
self.assertIn("http://example.org/concept/infrared", uris)
def test_search_concepts_with_scheme_filter(self):
self.mock_store.execute_query.return_value = self._make_result([
{"concept": {"value": "http://example.org/concept/red"},
"label": {"value": "Red"}},
])
results = self.engine.search_concepts("red", scheme_uri="http://example.org/vocab/colours")
self.assertEqual(len(results), 1)
# Scheme URI should appear in the SPARQL issued to the store
issued_sparql = self.mock_store.execute_query.call_args[0][0]
self.assertIn("http://example.org/vocab/colours", issued_sparql)
def test_search_concepts_empty_result(self):
self.mock_store.execute_query.return_value = self._make_result([])
results = self.engine.search_concepts("zzznomatch")
self.assertEqual(results, [])
def test_search_concepts_no_store_raises(self):
from semantica.utils.exceptions import ProcessingError
from semantica.ontology.engine import OntologyEngine
engine_no_store = OntologyEngine()
with self.assertRaises(ProcessingError):
engine_no_store.search_concepts("red")
def test_search_concepts_sanitizes_query(self):
"""Ensure user input containing SPARQL-special chars doesn't break the query."""
self.mock_store.execute_query.return_value = self._make_result([])
# Should not raise
self.engine.search_concepts('red" } MALICIOUS { ?x ?y ?z')
def test_search_concepts_deduplicates(self):
# Same concept URI matched by both prefLabel and altLabel
self.mock_store.execute_query.return_value = self._make_result([
{"concept": {"value": "http://example.org/concept/red"},
"label": {"value": "Red"}},
{"concept": {"value": "http://example.org/concept/red"},
"label": {"value": "Reddish"}},
])
results = self.engine.search_concepts("red")
self.assertEqual(len(results), 1)
if __name__ == '__main__':
unittest.main()
+180
View File
@@ -162,3 +162,183 @@ class TestTripletStore(unittest.TestCase):
self.assertIn("http://aligned.org/2", sparql_query)
self.assertIn("VALUES ?subject", sparql_query)
mock_backend.execute_sparql.assert_called_once()
class TestSKOSTripletStore(unittest.TestCase):
"""Tests for SKOS helper methods on TripletStore."""
_SKOS = "http://www.w3.org/2004/02/skos/core#"
_RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
def setUp(self):
self.mock_logger = MagicMock()
self.mock_tracker = MagicMock()
self.logger_patcher = patch(
'semantica.triplet_store.triplet_store.get_logger', return_value=self.mock_logger
)
self.tracker_patcher = patch(
'semantica.triplet_store.triplet_store.get_progress_tracker', return_value=self.mock_tracker
)
self.logger_patcher.start()
self.tracker_patcher.start()
def tearDown(self):
self.logger_patcher.stop()
self.tracker_patcher.stop()
def _make_store(self, mock_blazegraph):
"""Return a TripletStore backed by a MagicMock BlazegraphStore."""
mock_backend = MagicMock()
mock_blazegraph.return_value = mock_backend
store = TripletStore(backend="blazegraph")
# Provide a fast no-op bulk loader
mock_loader = MagicMock()
mock_progress = MagicMock()
mock_progress.metadata = {"success": True}
mock_progress.total_triplets = 0
mock_progress.loaded_triplets = 0
mock_progress.failed_triplets = 0
mock_progress.total_batches = 0
mock_loader.load_triplets.return_value = mock_progress
store.bulk_loader = mock_loader
return store, mock_backend, mock_loader
# --- add_skos_concept ---
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_add_skos_concept_core_triples(self, mock_bg):
"""add_skos_concept must produce ConceptScheme + Concept + inScheme + prefLabel triples."""
store, _, mock_loader = self._make_store(mock_bg)
store.add_skos_concept(
concept_uri="http://example.org/concept/red",
scheme_uri="http://example.org/vocab/colours",
pref_label="Red",
)
mock_loader.load_triplets.assert_called_once()
triplets = mock_loader.load_triplets.call_args[0][0]
subjects_predicates = {(t.subject, t.predicate) for t in triplets}
SKOS = self._SKOS
RDF_TYPE = self._RDF_TYPE
self.assertIn(("http://example.org/vocab/colours", RDF_TYPE), subjects_predicates)
self.assertIn(("http://example.org/concept/red", RDF_TYPE), subjects_predicates)
self.assertIn(("http://example.org/concept/red", f"{SKOS}inScheme"), subjects_predicates)
self.assertIn(("http://example.org/concept/red", f"{SKOS}prefLabel"), subjects_predicates)
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_add_skos_concept_optional_fields(self, mock_bg):
"""Optional fields produce extra triples."""
store, _, mock_loader = self._make_store(mock_bg)
store.add_skos_concept(
concept_uri="http://example.org/concept/red",
scheme_uri="http://example.org/vocab/colours",
pref_label="Red",
alt_labels=["Crimson", "Rouge"],
broader=["http://example.org/concept/colour"],
definition="The colour red.",
notation="RED",
)
triplets = mock_loader.load_triplets.call_args[0][0]
predicates = [t.predicate for t in triplets]
SKOS = self._SKOS
self.assertIn(f"{SKOS}altLabel", predicates)
self.assertEqual(predicates.count(f"{SKOS}altLabel"), 2)
self.assertIn(f"{SKOS}broader", predicates)
self.assertIn(f"{SKOS}definition", predicates)
self.assertIn(f"{SKOS}notation", predicates)
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_add_skos_concept_scheme_triple_always_included(self, mock_bg):
"""ConceptScheme rdf:type triple is always included even without optional args."""
store, _, mock_loader = self._make_store(mock_bg)
store.add_skos_concept(
concept_uri="http://example.org/concept/blue",
scheme_uri="http://example.org/vocab/colours",
pref_label="Blue",
)
triplets = mock_loader.load_triplets.call_args[0][0]
scheme_types = [
t for t in triplets
if t.subject == "http://example.org/vocab/colours"
and t.predicate == self._RDF_TYPE
and t.object == f"{self._SKOS}ConceptScheme"
]
self.assertEqual(len(scheme_types), 1)
# --- get_skos_concepts ---
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_get_skos_concepts_all(self, mock_bg):
"""get_skos_concepts returns all concepts when no scheme_uri given."""
store, mock_backend, _ = self._make_store(mock_bg)
from semantica.triplet_store.query_engine import QueryResult
mock_result = QueryResult(
bindings=[
{"concept": {"value": "http://example.org/concept/red"},
"prefLabel": {"value": "Red"},
"altLabel": {"value": "Crimson"},
"broader": None, "narrower": None, "related": None},
{"concept": {"value": "http://example.org/concept/red"},
"prefLabel": {"value": "Red"},
"altLabel": {"value": "Rouge"},
"broader": None, "narrower": None, "related": None},
{"concept": {"value": "http://example.org/concept/blue"},
"prefLabel": {"value": "Blue"},
"altLabel": None,
"broader": None, "narrower": None, "related": None},
],
variables=["concept", "prefLabel", "altLabel"],
)
mock_backend.execute_sparql.return_value = {
"bindings": mock_result.bindings,
"variables": mock_result.variables,
"metadata": {},
}
# Patch query_engine.execute_query to return mock_result directly
store.query_engine.execute_query = MagicMock(return_value=mock_result)
concepts = store.get_skos_concepts()
self.assertEqual(len(concepts), 2)
red = next(c for c in concepts if "red" in c["uri"])
self.assertEqual(red["pref_label"], "Red")
self.assertIn("Crimson", red["alt_labels"])
self.assertIn("Rouge", red["alt_labels"])
blue = next(c for c in concepts if "blue" in c["uri"])
self.assertEqual(blue["alt_labels"], [])
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_get_skos_concepts_scheme_filter_in_query(self, mock_bg):
"""When scheme_uri is given the scheme URI appears in the issued SPARQL."""
store, _, _ = self._make_store(mock_bg)
from semantica.triplet_store.query_engine import QueryResult
empty_result = QueryResult(bindings=[], variables=[])
store.query_engine.execute_query = MagicMock(return_value=empty_result)
store.get_skos_concepts(scheme_uri="http://example.org/vocab/colours")
issued_sparql = store.query_engine.execute_query.call_args[0][0]
self.assertIn("http://example.org/vocab/colours", issued_sparql)
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_get_skos_concepts_empty_store(self, mock_bg):
"""Returns empty list when no concepts exist."""
store, _, _ = self._make_store(mock_bg)
from semantica.triplet_store.query_engine import QueryResult
store.query_engine.execute_query = MagicMock(
return_value=QueryResult(bindings=[], variables=[])
)
self.assertEqual(store.get_skos_concepts(), [])