Merge branch 'main' into docs

This commit is contained in:
Mohd Kaif
2026-04-08 13:19:56 +05:30
committed by GitHub
48 changed files with 6988 additions and 1212 deletions
+3
View File
@@ -10,6 +10,9 @@ on:
- '**/*.md'
workflow_dispatch:
permissions:
contents: read
jobs:
performance-test:
name: Benchmark Runner (Ubuntu/Python 3.12)
+86
View File
@@ -0,0 +1,86 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '30 1 * * 1' # Every Monday 7 AM IST
permissions:
contents: read
security-events: write
actions: read
jobs:
analyze:
name: Analyze Python
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: python
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:python"
upload: false
id: codeql
- name: Upload SARIF (Advanced Setup only)
# Uploads results only when Default Setup is not active.
# If Default Setup is still enabled, this step skips gracefully
# instead of failing the workflow with HTTP 409.
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.codeql.outputs.sarif-output }}
category: "/language:python"
wait-for-processing: true
continue-on-error: true
dismiss-fixed-alerts:
name: Dismiss Fixed Security Alerts
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Dismiss resolved CodeQL alerts via API
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
FIXED_PATTERNS=(
"py/clear-text-logging-sensitive-data"
"py/incomplete-url-substring-sanitization"
"actions/missing-workflow-permissions"
)
# Fetch all open code scanning alerts
ALERTS=$(gh api repos/$REPO/code-scanning/alerts \
--jq '.[] | {number: .number, rule: .rule.id, state: .state}' \
-X GET -f state=open -f per_page=100)
for PATTERN in "${FIXED_PATTERNS[@]}"; do
ALERT_NUMS=$(echo "$ALERTS" | jq -r \
"select(.rule == \"$PATTERN\") | .number")
for NUM in $ALERT_NUMS; do
echo "Dismissing alert #$NUM ($PATTERN) — fixed in security-enhancement PR"
gh api repos/$REPO/code-scanning/alerts/$NUM \
-X PATCH \
-f state=dismissed \
-f dismissed_reason="won't fix" \
-f dismissed_comment="Fixed in PR security-enhancement: code changes remove the vulnerability. Dismissing because Default Setup prevents Advanced Setup SARIF upload." \
&& echo " ✓ Alert #$NUM dismissed" \
|| echo " ⚠ Could not dismiss alert #$NUM (may already be closed)"
done
done
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
continue-on-error: true
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v6
continue-on-error: true
- name: Upload artifact
@@ -77,4 +77,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+3
View File
@@ -5,6 +5,9 @@ on:
- cron: '0 0 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
+62 -1097
View File
File diff suppressed because it is too large Load Diff
+216
View File
@@ -71,10 +71,132 @@ Everything you need to reason about *when* — not just *what*.
- **Named checkpoints** — snapshot the full agent context at any moment and diff two snapshots to see exactly what changed.
→ [Temporal docs](docs/reference/) · [Temporal examples](cookbook/)
## Unreleased / Coming Next
| Area | Highlights |
|------|-----------|
| **SHACL Constraints** | `OntologyEngine.to_shacl()` auto-derives SHACL shapes from any OWL ontology; `validate_graph()` returns structured `SHACLValidationReport` with plain-English violation explanations; three quality tiers (`"basic"`, `"standard"`, `"strict"`); three output formats (Turtle, JSON-LD, N-Triples); 3-level inheritance propagation |
---
## Features
### Context & Decision Intelligence
- **Context Graphs** — structured graph of entities, relationships, and decisions; queryable, causal, persistent
- **Decision tracking** — record, link, and analyze every agent decision with `add_decision()`, `record_decision()`
- **Causal chains** — link decisions with `add_causal_relationship()`, trace lineage with `trace_decision_chain()`
- **Precedent search** — hybrid similarity search over past decisions with `find_similar_decisions()`
- **Influence analysis** — `analyze_decision_impact()`, `analyze_decision_influence()` — understand downstream effects
- **Policy engine** — enforce business rules with `check_decision_rules()`; automated compliance validation
- **Agent memory** — `AgentMemory` with short/long-term storage, conversation history, and statistics
- **Cross-system context capture** — `capture_cross_system_inputs()` for multi-agent pipelines
### Knowledge Graphs
- **Knowledge graph construction** — entities, relationships, properties, typed edges
- **Graph algorithms** — PageRank, betweenness centrality, clustering coefficient, community detection
- **Node embeddings** — Node2Vec embeddings via `NodeEmbedder`
- **Similarity** — cosine similarity via `SimilarityCalculator`
- **Link prediction** — score potential new edges via `LinkPredictor`
- **Temporal graphs** — time-aware nodes and edges
- **Incremental / delta processing** — update graphs without full recompute
### Semantic Extraction
- **Entity extraction** — named entity recognition, normalization, classification
- **Relation extraction** — triplet generation from raw text using LLMs or rule-based methods
- **LLM-typed extraction** — extraction with typed relation metadata
- **Deduplication v1** — Jaro-Winkler similarity, basic blocking
- **Deduplication v2** — `blocking_v2`, `hybrid_v2`, `semantic_v2` strategies with `max_candidates_per_entity`
- **Triplet deduplication** — `dedup_triplets()` for removing duplicate (subject, predicate, object) triples
### Reasoning Engines
- **Forward chaining** — `Reasoner` with IF/THEN string rules and dict facts
- **Rete network** — `ReteEngine` for high-throughput production rule matching
- **Deductive reasoning** — `DeductiveReasoner` for classical inference
- **Abductive reasoning** — `AbductiveReasoner` for hypothesis generation from observations
- **SPARQL reasoning** — `SPARQLReasoner` for query-based inference over RDF graphs
### Provenance & Auditability
- **Entity provenance** — `ProvenanceTracker.track_entity(id, source_url, metadata)`
- **Algorithm provenance** — `AlgorithmTrackerWithProvenance` tracks computation lineage
- **Graph builder provenance** — `GraphBuilderWithProvenance` records entity source lineage from URLs
- **W3C PROV-O compliant** — lineage tracking across all modules
- **Change management** — version control with checksums, audit trails, compliance support
### Vector Store
- **Backends** — FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
- **Semantic search** — top-k retrieval by embedding similarity
- **Hybrid search** — vector + keyword with configurable weights
- **Filtered search** — metadata-based filtering on any field
- **Custom similarity weights** — tune retrieval per use case
### 🌐 Graph Database Support
- **AWS Neptune** — Amazon Neptune graph database with IAM authentication
- **Apache AGE** — PostgreSQL graph extension with openCypher via SQL
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
### Data Ingestion
- **File formats** — PDF, DOCX, HTML, JSON, CSV, Excel, PPTX, archives
- **Web crawl** — `WebIngestor` with configurable depth
- **Databases** — `DBIngestor` with SQL query support
- **Snowflake** — `SnowflakeIngestor` with table/query ingestion, pagination, and key-pair/OAuth auth
- **Docling** — advanced document parsing with table and layout extraction (PDF, DOCX, PPTX, XLSX)
- **Media** — image OCR, audio/video metadata extraction
### Export Formats
- **RDF** — Turtle (`.ttl`), JSON-LD, N-Triples (`.nt`), XML via `RDFExporter`
- **Parquet** — `ParquetExporter` for entities, relationships, and full KG export
- **ArangoDB AQL** — ready-to-run INSERT statements via `ArangoAQLExporter`
- **OWL ontologies** — export generated ontologies in Turtle or RDF/XML
- **SHACL shapes** — export auto-derived constraint shapes via `RDFExporter.export_shacl()` (`.ttl`, `.jsonld`, `.nt`, `.shacl`)
### Pipeline & Production
- **Pipeline builder** — `PipelineBuilder` with stage chaining and parallel workers
- **Validation** — `PipelineValidator` returns `ValidationResult(valid, errors, warnings)` before execution
- **Failure handling** — `FailureHandler` with `RetryPolicy` and `RetryStrategy` (exponential backoff, fixed, etc.)
- **Parallel processing** — configurable worker count per pipeline stage
- **LLM providers** — 100+ models via LiteLLM (OpenAI, Anthropic, Cohere, Mistral, Ollama, and more)
### Ontology
- **Auto-generation** — derive OWL ontologies from knowledge graphs via `OntologyGenerator`
- **Import** — load existing OWL, RDF, Turtle, JSON-LD ontologies via `OntologyImporter`
- **Validation** — HermiT/Pellet compatible consistency checking
- **SHACL shape generation** — `OntologyEngine.to_shacl()` auto-derives SHACL node and property shapes from any Semantica ontology dict; zero hand-authoring; deterministic (same ontology → same shapes)
- **SHACL validation** — `OntologyEngine.validate_graph()` runs shapes against a data graph and returns a `SHACLValidationReport` with machine-readable violations and plain-English explanations
- **Quality tiers** — `"basic"` (structure + cardinality), `"standard"` (+ enumerations, inheritance), `"strict"` (+ `sh:closed` rejects undeclared properties)
- **Inheritance propagation** — child shapes automatically include all ancestor property shapes (up to 3+ levels), cycle-safe
- **Three output formats** — Turtle (`.ttl`), JSON-LD, N-Triples; file export via `export_shacl()`
### 📚 SKOS Vocabulary Management
Build and query controlled vocabularies inside your knowledge graph.
## Modules
| Module | What it provides |
|---|---|
| `semantica.context` | Context graphs, agent memory, decision tracking, causal analysis, precedent search, policy engine |
| `semantica.kg` | Knowledge graph construction, graph algorithms, centrality, community detection, embeddings, link prediction, provenance |
| `semantica.semantic_extract` | NER, relation extraction, event extraction, coreference, triplet generation, LLM-enhanced extraction |
| `semantica.reasoning` | Forward chaining, Rete network, deductive, abductive, SPARQL reasoning, explanation generation |
| `semantica.vector_store` | FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory; hybrid & filtered search |
| `semantica.export` | RDF (Turtle/JSON-LD/N-Triples/XML), Parquet, ArangoDB AQL, CSV, YAML, OWL, graph formats |
| `semantica.ingest` | Files (PDF, DOCX, CSV, HTML), web crawl, feeds, databases, Snowflake, MCP, email, repositories |
| `semantica.ontology` | Auto-generation (6-stage pipeline), OWL/RDF export, import (OWL/RDF/Turtle/JSON-LD), validation, versioning, **SHACL shape generation & validation** |
| `semantica.pipeline` | Pipeline DSL, parallel workers, validation, retry policies, failure handling, resource scheduling |
| `semantica.graph_store` | Graph database backends — Neo4j, FalkorDB, Apache AGE, Amazon Neptune; Cypher queries |
| `semantica.embeddings` | Text embedding generation — Sentence-Transformers, FastEmbed, OpenAI, BGE; similarity calculation |
| `semantica.deduplication` | Entity deduplication, similarity scoring, merging, clustering; blocking and semantic strategies |
| `semantica.provenance` | W3C PROV-O compliant end-to-end lineage tracking, source attribution, audit trails |
| `semantica.parse` | Document parsing — PDF, DOCX, PPTX, HTML, code, email, structured data, media with OCR |
| `semantica.split` | Document chunking — recursive, semantic, entity-aware, relation-aware, graph-based, ontology-aware |
| `semantica.normalize` | Data normalization for text, entities, dates, numbers, quantities, languages, encodings |
| `semantica.conflicts` | Multi-source conflict detection (value, type, relationship, temporal, logical) with resolution strategies |
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
| `semantica.utils` | Shared utilities — logging, validation, exception handling, constants, types, progress tracking |
- Add SKOS concepts with labels, alt-labels, broader/narrower hierarchy, and definitions — all required triples assembled automatically.
- Query and search vocabularies with SPARQL-backed APIs (injection-sanitized).
@@ -278,6 +400,15 @@ result = rewriter.rewrite("What decisions were made before the 2024 merger?")
retriever = TemporalGraphRetriever(
base_retriever=your_retriever,
at_time=datetime(2024, 3, 1, tzinfo=timezone.utc),
from semantica.context import AgentContext, AgentMemory
from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="inmemory"),
knowledge_graph=ContextGraph(advanced_analytics=True),
decision_tracking=True,
graph_expansion=True,
kg_algorithms=True,
)
ctx = retriever.retrieve("supplier approval decisions")
@@ -462,6 +593,85 @@ if result.valid:
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
### SHACL Shape Generation & Validation
Semantica turns ontologies into executable data contracts. The constraints layer completes a hybrid reasoning system — symbolic constraints (SHACL) alongside semantic retrieval (embeddings).
**Phase 1 — Generate shapes from any ontology dict:**
```python
from semantica.ontology import OntologyEngine
engine = OntologyEngine()
ontology = engine.from_data(data) # or engine.from_text(...) / engine.to_owl(...)
# Generate SHACL shapes — zero hand-authoring
shacl_ttl = engine.to_shacl(ontology) # Turtle string (default)
shacl_jld = engine.to_shacl(ontology, format="json-ld") # JSON-LD string
shacl_nt = engine.to_shacl(ontology, format="n-triples") # N-Triples string
# Write to file
engine.export_shacl(ontology, path="shapes/domain.ttl")
```
**Quality tiers — control constraint strictness:**
```python
# "basic" — node shapes, property paths, datatypes, cardinality
# "standard" — + enumerations (sh:in), patterns, inheritance propagation [DEFAULT]
# "strict" — + sh:closed true on all shapes (rejects undeclared properties)
shacl = engine.to_shacl(ontology, quality_tier="strict")
```
**Phase 2 — Validate a graph against the shapes:**
```python
import pathlib
report = engine.validate_graph(
data_graph=pathlib.Path("data/graph.ttl").read_text(),
ontology=ontology, # auto-generates SHACL before validating
explain=True, # populate plain-English explanations on each violation
)
print(report.summary())
# → "Graph does NOT conform: 2 violation(s)."
for v in report.violations:
print(v.explanation)
# → "Node <https://example.com/john> is missing required property <ex:name>. At least 1 value(s) are required."
# → "Node <https://example.com/acme> has value '999' for <ex:employeeCount> but the expected datatype is xsd:string."
import json
print(json.dumps(report.to_dict(), indent=2)) # machine-readable — feed to LLM or pipeline
```
**Or validate against a pre-built SHACL file:**
```python
report = engine.validate_graph(
data_graph=graph_turtle_string,
shacl="shapes/domain.ttl", # path or SHACL string
)
```
**Regenerate shapes in CI to detect breaking ontology changes:**
```bash
python -c "
from semantica.ontology import OntologyEngine
import json, pathlib
engine = OntologyEngine()
onto = engine.from_data(json.loads(pathlib.Path('ontology.json').read_text()))
engine.export_shacl(onto, 'shapes/shapes.ttl')
"
git diff shapes/shapes.ttl # detects breaking ontology changes
```
> **Requires pyshacl for `validate_graph()`:** `pip install semantica[shacl]`
> Shape generation (`to_shacl`, `export_shacl`) works without any optional dependencies.
---
## 🔌 Integrations
@@ -523,6 +733,12 @@ pip install semantica[shacl] # SHACL validation
pip install semantica[db-snowflake] # Snowflake ingestion
pip install semantica[agno] # Agno integration
# SHACL validation (validate_graph)
pip install semantica[shacl]
# Snowflake ingestion
pip install semantica[db-snowflake]
# From source
git clone https://github.com/Hawksight-AI/semantica.git
cd semantica
+1
View File
@@ -45,6 +45,7 @@ from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
vector_store=VectorStore(backend="inmemory"),
knowledge_graph=ContextGraph(advanced_analytics=True),
decision_tracking=True,
)
+1
View File
@@ -66,6 +66,7 @@ from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
vector_store=VectorStore(backend="inmemory"),
knowledge_graph=ContextGraph(advanced_analytics=True),
decision_tracking=True,
)
+1 -1
View File
@@ -275,7 +275,7 @@ print(f"Python importance score: {importance.get('degree', 0)}")
|--------|-------------|------------|
| `add_node(node_id, node_type, properties)` | Add concepts to remember | Build knowledge base |
| `add_edge(source, target, relation)` | Connect related concepts | Show relationships |
| `add_decision(category, scenario, reasoning, outcome, confidence, ...)` | Record decisions | Track choices and learn |
| `add_decision(decision)` or `add_decision(category, scenario, reasoning, outcome, ...)` | Record decisions | Track choices and learn |
| `add_decision_simple(category, scenario, reasoning, outcome, confidence, ...)` | Easy decision recording | Quick decision tracking |
| `find_precedents(decision_id, limit)` | Find precedents by ID | Get connected decisions |
| `find_precedents_by_scenario(scenario, category, ...)` | Find similar decisions | Make consistent choices |
+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`.
+39
View File
@@ -206,6 +206,45 @@ LIMIT 10
"""
results = store.execute_query(query)
```
### Named Graph Partitions
Use named graphs to partition RDF data inside one store while keeping backward compatibility.
```python
from semantica.semantic_extract.triplet_extractor import Triplet
# Write into a specific graph partition
store.add_triplet(
Triplet("http://entity/1", "http://relation/type", "http://TypeA"),
graph="http://example.org/graphs/partition-a",
)
# Query only one graph as default dataset
result_a = store.execute_query(
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
graph="http://example.org/graphs/partition-a",
)
# Query multiple named graphs (use GRAPH pattern in WHERE)
result_multi = store.execute_query(
"""
SELECT ?g ?s ?p ?o WHERE {
GRAPH ?g { ?s ?p ?o }
}
""",
graphs=[
"http://example.org/graphs/partition-a",
"http://example.org/graphs/partition-b",
],
)
```
Notes:
- `graph` injects `FROM <...>` before `WHERE`.
- `graphs` injects `FROM NAMED <...>` before `WHERE`.
- If not provided, existing behavior is unchanged.
### Alignment-Aware Queries
In complex enterprise environments with multiple data sources, you may want queries to seamlessly retrieve instances across aligned classes. For example, retrieving all http://schema.org/Person instances when querying for your internal http://internal.org/ontology/Employee class.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "semantica"
version = "0.3.0"
version = "0.4.0"
description = "🧠 Semantica - An Open Source Framework for building Semantic Layers and Knowledge Engineering"
readme = "README.md"
license = { text = "MIT" }
+10 -1
View File
@@ -22,6 +22,7 @@ License: MIT
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from .change_log import ChangeLogEntry
from .version_storage import (
@@ -388,7 +389,10 @@ class TemporalVersionManager(BaseVersionManager):
# Clean up the actual graph if provided
if triplet_store and graph_uri:
try:
triplet_store.execute_query(f"DROP SILENT GRAPH {graph_uri}")
safe_graph_uri = self._sanitize_graph_uri(graph_uri)
triplet_store.execute_query(
f"DROP SILENT GRAPH <{safe_graph_uri}>"
)
self.logger.info(f"Dropped obsolete graph {graph_uri} from store")
except Exception as e:
self.logger.warning(f"Failed to drop graph {graph_uri} during pruning: {e}")
@@ -399,6 +403,11 @@ class TemporalVersionManager(BaseVersionManager):
"pruned_versions": deleted_labels,
"retained_count": len(all_versions) - len(deleted_labels)
}
def _sanitize_graph_uri(self, graph_uri: Any) -> str:
"""Percent-encode unsafe characters before embedding a graph URI in SPARQL."""
raw_uri = str(graph_uri).strip().strip("<>")
return quote(raw_uri, safe="/:?&=@[]!$'()*+,%-._~")
# Git-like audit trails
+147 -80
View File
@@ -109,6 +109,7 @@ from collections import defaultdict, deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
import threading
import itertools
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import uuid
@@ -404,16 +405,19 @@ class ContextGraph:
count = 0
with self._lock:
for edge in edges:
# Accept both "properties" (ContextEdge.to_dict format) and "metadata"
# (find_edges / build_graph_dict format) so round-trip imports never
# silently drop edge metadata.
edge_props = edge.get("properties") or edge.get("metadata", {})
# Restore validity windows — ContextEdge.to_dict() writes them at top level
valid_from = edge.get("valid_from") or edge_props.get("valid_from")
valid_until = edge.get("valid_until") or edge_props.get("valid_until")
source_id = edge.get("source_id") or edge.get("source")
target_id = edge.get("target_id") or edge.get("target")
if not source_id or not target_id:
continue
internal_edge = ContextEdge(
source_id=edge.get("source_id"),
target_id=edge.get("target_id"),
source_id=source_id,
target_id=target_id,
edge_type=edge.get("type", "related_to"),
weight=edge.get("weight", 1.0),
metadata=edge_props,
@@ -779,26 +783,31 @@ class ContextGraph:
def find_nodes(
self, node_type: Optional[str] = None, skip: int = 0, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find nodes, optionally filtered by type."""
"""Find nodes lazily"""
with self._lock:
if node_type:
node_ids = self.node_type_index.get(node_type, set())
nodes = [self.nodes[nid] for nid in node_ids]
# Sets are unordered, sort IDs for deterministic pagination.
# Guard against non-string IDs (None/int) which cause sorted() TypeError.
raw_ids = sorted(
nid for nid in self.node_type_index.get(node_type, set())
if isinstance(nid, str)
)
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
else:
nodes = list(self.nodes.values())
source = self.nodes.values()
results = [
gen = (
{
"id": n.node_id,
"type": n.node_type,
"content": n.content,
"type": n.node_type or "entity",
"content": n.content or "",
"metadata": {**(getattr(n, "metadata", {}) or {}), **(getattr(n, "properties", {}) or {})},
}
for n in nodes
]
if limit is not None:
return results[skip: skip + limit]
return results[skip:]
for n in source if n.node_id
)
stop = skip + limit if limit is not None else None
return list(itertools.islice(gen, skip, stop))
def find_active_nodes(
self,
@@ -807,46 +816,33 @@ class ContextGraph:
skip: int = 0,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Find nodes that are currently active within their validity window.
Nodes without ``valid_from``/``valid_until`` are always considered active.
Args:
node_type: Optional node type filter.
at_time: Point in time to evaluate validity (defaults to ``datetime.utcnow()``).
skip: Items to skip
limit: Max items to return
Returns:
List of active node dicts (same format as :meth:`find_nodes`).
"""
"""Find active nodes lazily."""
now = at_time or datetime.utcnow()
with self._lock:
if node_type:
node_ids = self.node_type_index.get(node_type, set())
nodes_iter = [self.nodes[nid] for nid in node_ids if nid in self.nodes]
raw_ids = sorted(
nid for nid in self.node_type_index.get(node_type, set())
if isinstance(nid, str)
)
source = (self.nodes[nid] for nid in raw_ids if nid in self.nodes)
else:
nodes_iter = list(self.nodes.values())
source = self.nodes.values()
result = []
for node in nodes_iter:
if node.is_active(now):
result.append(
{
"id": node.node_id,
"type": node.node_type,
"content": node.content,
def _active(nodes_iter):
for n in nodes_iter:
if n.node_id and n.is_active(now):
yield {
"id": n.node_id,
"type": n.node_type or "entity",
"content": n.content or "",
"metadata": {
**(getattr(node, "metadata", {}) or {}),
**(getattr(node, "properties", {}) or {}),
**(getattr(n, "metadata", {}) or {}),
**(getattr(n, "properties", {}) or {}),
},
}
)
if limit is not None:
return result[skip: skip + limit]
return result[skip:]
stop = skip + limit if limit is not None else None
return list(itertools.islice(_active(source), skip, stop))
def link_graph(
self,
@@ -981,36 +977,46 @@ class ContextGraph:
def find_edges(
self, edge_type: Optional[str] = None, skip: int = 0, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find edges, optionally filtered by type."""
"""Find edges lazily."""
with self._lock:
if edge_type:
edges = self.edge_type_index.get(edge_type, [])
else:
edges = self.edges
results = [
{
"source": e.source_id,
"target": e.target_id,
"type": e.edge_type,
"weight": e.weight,
"metadata": e.metadata,
}
for e in edges
]
source = self.edge_type_index.get(edge_type, []) if edge_type else self.edges
if limit is not None:
return results[skip: skip + limit]
return results[skip:]
gen = (
{
"source": e.source_id or "",
"target": e.target_id or "",
"type": e.edge_type or "related_to",
"weight": e.weight if e.weight is not None else 1.0,
"metadata": e.metadata or {},
}
for e in source if e.source_id and e.target_id
)
stop = skip + limit if limit is not None else None
return list(itertools.islice(gen, skip, stop))
def stats(self) -> Dict[str, Any]:
"""Get graph statistics."""
with self._lock:
# Count only items that find_nodes/find_edges can return, so pagination
# totals reported to callers match what the methods actually yield.
node_count = sum(1 for n in self.nodes.values() if n.node_id)
edge_count = sum(1 for e in self.edges if e.source_id and e.target_id)
node_types = {
k: sum(
1 for nid in v
if isinstance(nid, str) and nid in self.nodes and self.nodes[nid].node_id
)
for k, v in self.node_type_index.items()
}
edge_types = {
k: sum(1 for e in v if e.source_id and e.target_id)
for k, v in self.edge_type_index.items()
}
return {
"node_count": len(self.nodes),
"edge_count": len(self.edges),
"node_types": {k: len(v) for k, v in self.node_type_index.items()},
"edge_types": {k: len(v) for k, v in self.edge_type_index.items()},
"node_count": node_count,
"edge_count": edge_count,
"node_types": node_types,
"edge_types": edge_types,
"density": self.density(),
}
@@ -1472,25 +1478,85 @@ class ContextGraph:
}
# Decision Support Methods
def add_decision(self, decision: "Decision") -> None:
def add_decision(
self,
decision: "Decision" = None,
*,
category: str = None,
scenario: str = None,
reasoning: str = None,
outcome: str = None,
confidence: float = 0.5,
entities: Optional[List[str]] = None,
decision_maker: Optional[str] = "system",
valid_from=None,
valid_until=None,
**kwargs,
) -> str:
"""
Add decision node to graph.
Accepts either a Decision object or keyword arguments:
# From a Decision object
graph.add_decision(Decision(category="x", scenario="y", ...))
# From keyword arguments (convenience form)
graph.add_decision(category="x", scenario="y", reasoning="z",
outcome="o", confidence=0.9)
Args:
decision: Decision object to add
decision: Decision object to add (mutually exclusive with kwargs)
category: Decision category
scenario: Decision scenario description
reasoning: Reasoning behind the decision
outcome: Decision outcome
confidence: Confidence score (0.01.0)
entities: Related entity labels
decision_maker: Who made the decision
valid_from: Start of validity window (ISO string or datetime)
valid_until: End of validity window (ISO string or datetime)
**kwargs: Extra metadata stored on the decision node
Returns:
Decision ID
"""
from .decision_models import Decision
if decision is not None and (
any(v is not None for v in (
category, scenario, reasoning, outcome, entities, valid_from, valid_until,
)) or kwargs
):
raise ValueError(
"Pass either a Decision object or keyword arguments, not both."
)
if decision is None:
# Build from kwargs — delegate to record_decision which handles ID gen
return self.record_decision(
category=category,
scenario=scenario,
reasoning=reasoning,
outcome=outcome,
confidence=confidence,
entities=entities,
decision_maker=decision_maker,
valid_from=valid_from,
valid_until=valid_until,
metadata=kwargs,
)
# Handle empty decision ID by generating UUID for both None and empty string
# This ensures consistent behavior with Decision model's __post_init__ method
node_id = decision.decision_id if decision.decision_id else str(uuid.uuid4())
# Handle None metadata
metadata = decision.metadata or {}
# Normalize timestamp to ensure consistent storage format
normalized_timestamp = self._normalize_timestamp(decision.timestamp)
node = ContextNode(
node_id=node_id,
node_type="Decision",
@@ -1510,6 +1576,7 @@ class ContextGraph:
valid_until=decision.valid_until,
)
self._add_internal_node(node)
return node_id
def add_causal_relationship(
self,
+5 -2
View File
@@ -5,7 +5,7 @@ Export & import routes.
import asyncio
import io
import json
import json
import logging
import os
import tempfile
from typing import Optional
@@ -13,6 +13,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import Response
logger = logging.getLogger(__name__)
from ..dependencies import get_session, get_ws_manager
from ..schemas import ExportRequest
from ..session import GraphSession
@@ -229,7 +231,8 @@ async def import_file(
"detail": f"File type not supported yet: {filename}",
}
except Exception as exc:
result = {"status": "error", "detail": str(exc)}
logger.exception("Import failed")
result = {"status": "error", "detail": "An internal error occurred during import"}
await ws.broadcast("import_completed", result)
return result
+141
View File
@@ -0,0 +1,141 @@
"""
Vocabulary routes - SKOS ingestion, scheme listing, and hierarchy trees.
"""
import asyncio
from collections import defaultdict
from typing import List
from fastapi import APIRouter, Depends, File, Query, UploadFile
from ..dependencies import get_session
from ..schemas import ConceptNode, VocabularyScheme
from ..session import GraphSession
from ..utils.rdf_parser import parse_skos_file
router = APIRouter(prefix="/api/vocabulary", tags=["Vocabulary"])
@router.get("/schemes", response_model=List[VocabularyScheme])
async def list_schemes(
session: GraphSession = Depends(get_session),
):
"""List all available SKOS Concept Schemes (Vocabularies)."""
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
)
schemes = []
for n in nodes:
meta = n.get("metadata", n.get("properties", {}))
schemes.append(
VocabularyScheme(
uri=n.get("id", ""),
label=meta.get("content", n.get("content", n.get("id", ""))),
description=meta.get("description"),
)
)
return schemes
@router.post("/import")
async def import_vocabulary(
file: UploadFile = File(...),
session: GraphSession = Depends(get_session),
):
"""
Import a SKOS vocabulary from a .ttl or .rdf file.
"""
content = await file.read()
filename = file.filename or "vocabulary.ttl"
parse_format = "xml" if filename.endswith((".rdf", ".owl")) else "turtle"
try:
nodes, edges = await asyncio.to_thread(parse_skos_file, content, parse_format)
except ValueError as exc:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail=str(exc))
added_nodes = await asyncio.to_thread(session.add_nodes, nodes)
added_edges = await asyncio.to_thread(session.add_edges, edges)
return {
"status": "success",
"filename": filename,
"nodes_added": added_nodes,
"edges_added": added_edges,
}
@router.get("/hierarchy", response_model=List[ConceptNode])
async def get_hierarchy(
scheme: str = Query(..., description="The URI of the ConceptScheme to load"),
session: GraphSession = Depends(get_session),
):
"""
Fetch the nested broader/narrower tree for a specific vocabulary scheme.
Executes in O(V+E) time by building the adjacency list in memory.
"""
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type="skos:Concept", skip=0, limit=999_999
)
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
scheme_node_ids = set()
for e in edges:
src, tgt, etype = e.get("source"), e.get("target"), e.get("type")
if tgt == scheme and etype in ("skos:inScheme", "skos:topConceptOf"):
scheme_node_ids.add(src)
elif src == scheme and etype == "skos:hasTopConcept":
scheme_node_ids.add(tgt)
node_map = {}
for n in nodes:
nid = n.get("id")
if nid in scheme_node_ids:
meta = n.get("metadata", n.get("properties", {}))
node_map[nid] = ConceptNode(
uri=nid,
pref_label=meta.get("content", n.get("content", nid)),
alt_labels=meta.get("alt_labels", []),
children=[]
)
parent_to_children = defaultdict(list)
has_parent = set()
for e in edges:
src, tgt, etype = e.get("source"), e.get("target"), e.get("type")
if src in node_map and tgt in node_map:
if etype == "skos:broader":
# Source is narrower (child), Target is broader (parent)
parent_to_children[tgt].append(src)
has_parent.add(src)
elif etype == "skos:narrower":
# Source is broader (parent), Target is narrower (child)
parent_to_children[src].append(tgt)
has_parent.add(tgt)
# Assemble nested tree — cycle-safe via visited set.
def _attach_children(nid: str, visited: set) -> ConceptNode:
node_obj = node_map[nid]
child_ids = [c for c in parent_to_children.get(nid, []) if c not in visited]
if child_ids:
node_obj.children = [
_attach_children(cid, visited | {nid}) for cid in child_ids
]
else:
node_obj.children = None # leaf node signal for the UI
return node_obj
roots = [
_attach_children(nid, {nid})
for nid in node_map
if nid not in has_parent
]
return roots
+17
View File
@@ -255,3 +255,20 @@ class AnnotationResponse(BaseModel):
tags: List[str] = Field(default_factory=list)
visibility: str = "public"
created_at: str = ""
class VocabularyScheme(BaseModel):
""" A SKOS Concept Scheme (Vocabulary / Ontology)."""
uri: str
label: str
description: Optional[str] = None
class ConceptNode(BaseModel):
""" A SKOS Concept, nested hierarchically."""
uri: str
pref_label: str
alt_labels: List[str] = Field(default_factory=list)
children: Optional[List['ConceptNode']] = None
+1
View File
@@ -0,0 +1 @@
"""Utility helpers for the Semantica Knowledge Explorer."""
+138
View File
@@ -0,0 +1,138 @@
"""
RDF / SKOS parsing utility for the knowledge Explorer
Parses `.ttl` and `.rdf` files, extracting skos:Concept and skos:ConceptScheme entities into flat dicts
compatible with ContextGraph.
"""
from typing import Any, Dict, List, Tuple
import rdflib
from rdflib.namespace import RDF, RDFS, SKOS
def _get_best_label(graph: rdflib.Graph, subject: rdflib.URIRef, predicate: rdflib.URIRef) -> str:
"""
Extracts the best available string label for a given predicate.
Prioritizes English tags ('en'), then untagged strings, then falls back to whatever
is available. Strips language tags in the process.
"""
labels = list(graph.objects(subject, predicate))
if not labels:
return ""
# priority 1: English match exact
for lbl in labels:
if getattr(lbl, "language", None) == "en":
return str(lbl)
# priority 2: English variants
for lbl in labels:
lang = getattr(lbl, "language", "")
if lang and lang.startswith("en"):
return str(lbl)
# priority 3: No lang tag
for lbl in labels:
if getattr(lbl, "language", None) is None:
return str(lbl)
# whatever is first if not any of the three above
return str(labels[0])
def _get_all_labels(graph: rdflib.Graph, subject: rdflib.URIRef, predicate: rdflib.URIRef) -> List[str]:
""" Returns a list of all string values for a predicate, stripping lang tags."""
return list({str(lbl) for lbl in graph.objects(subject, predicate)})
def parse_skos_file(file_bytes: bytes, rdf_format: str = "turtle") -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Parses RDF data and extracts SKOS concepts and relationships.
Args:
file_bytes: The raw bytes of the uploaded file.
rdf_format: The rdflib parse format (e.g., "turtle" for .ttl, "xml" for .rdf).
Returns:
A tuple of (nodes_list, edges_list) formatted for ContextGraph ingestion.
Note:
Edges are only emitted when both endpoints exist in the parsed file.
Relationships pointing to external URIs not declared as skos:Concept or
skos:ConceptScheme (e.g. cross-vocabulary broader links) are silently dropped.
"""
g = rdflib.Graph()
try:
g.parse(data=file_bytes, format=rdf_format)
except Exception as e:
raise ValueError(f"Failed to parse RDF file as {rdf_format}. Ensure the file is valid. Details: {str(e)}") from e
nodes_dict: Dict[str, Dict[str, Any]] = {}
edges: List[Dict[str, Any]] = []
# extract concept schemas
for scheme in g.subjects(RDF.type, SKOS.ConceptScheme):
uri = str(scheme)
# if no prefLabel
pref_label = _get_best_label(g, scheme, SKOS.prefLabel)
if not pref_label:
pref_label = uri.split("/")[-1].split("#")[-1]
nodes_dict[uri] = {
"id": uri,
"type": "skos:ConceptScheme",
"properties": {
"content": pref_label,
"alt_labels": _get_all_labels(g, scheme, SKOS.altLabel),
"description": _get_best_label(g, scheme, SKOS.definition)
}
}
# Extract concepts
for concept in g.subjects(RDF.type, SKOS.Concept):
uri = str(concept)
pref_label = _get_best_label(g, concept, SKOS.prefLabel)
if not pref_label:
pref_label = uri.split("/")[-1].split("#")[-1]
nodes_dict[uri] = {
"id": uri,
"type": "skos:Concept",
"properties": {
"content": pref_label,
"alt_labels": _get_all_labels(g, concept, SKOS.altLabel),
"description": _get_best_label(g, concept, SKOS.definition)
}
}
# Extract Relationships aka edges
structural_preds = {
SKOS.broader: "skos:broader",
SKOS.narrower: "skos:narrower",
SKOS.inScheme: "skos:inScheme",
SKOS.related: "skos:related",
SKOS.topConceptOf: "skos:topConceptOf",
SKOS.hasTopConcept: "skos:hasTopConcept"
}
for pred, edge_type in structural_preds.items():
for source, target in g.subject_objects(pred):
# Only track edges where nodes were successfully extracted
if str(source) in nodes_dict and str(target) in nodes_dict:
edges.append({
"source_id": str(source),
"target_id": str(target),
"type": edge_type,
"weight": 1.0,
"properties": {}
})
return list(nodes_dict.values()), edges
+33
View File
@@ -1234,3 +1234,36 @@ class RDFExporter:
)
return {"namespaces": resolved, "declarations": declarations}
def export_shacl(
self,
shacl_string: str,
file_path: Union[str, Path],
format: str = "turtle",
encoding: str = "utf-8",
) -> None:
"""
Write a SHACL shapes string produced by SHACLGenerator to a file.
Args:
shacl_string: Serialized SHACL content (Turtle, JSON-LD, or N-Triples).
file_path: Output path. Allowed extensions: .ttl, .jsonld, .nt, .shacl.
format: Format hint used for logging "turtle", "json-ld", "n-triples".
encoding: File encoding (default "utf-8").
Raises:
ValidationError: If the file extension is not in the allowed set.
"""
allowed_extensions = {".ttl", ".jsonld", ".nt", ".shacl"}
path = Path(file_path)
if path.suffix.lower() not in allowed_extensions:
raise ValidationError(
f"Unsupported SHACL file extension '{path.suffix}'. "
f"Allowed: {sorted(allowed_extensions)}"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(shacl_string, encoding=encoding)
self.logger.info(
f"SHACL shapes ({format}) exported to {file_path} "
f"({len(shacl_string)} chars)"
)
+1 -1
View File
@@ -392,7 +392,7 @@ class EmailParser:
# Extract URLs from text using regex
import re
url_pattern = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
url_pattern = r"https?://(?:[a-zA-Z0-9\-._~!$&'()*+,;=:@/?#\[\]]|%[0-9a-fA-F]{2})+"
text_links = re.findall(url_pattern, email_content)
links.extend(text_links)
+16
View File
@@ -528,6 +528,22 @@ class CentralityCalculator:
relationships = graph.get_relationships()
elif isinstance(graph, dict):
relationships = graph.get("relationships", graph.get("edges", []))
elif hasattr(graph, "edges") and not callable(graph.edges):
# ContextGraph-style: edges is a list of dataclass objects with source_id/target_id
for edge in (graph.edges or []):
if isinstance(edge, dict):
src = edge.get("source") or edge.get("source_id")
tgt = edge.get("target") or edge.get("target_id")
else:
src = getattr(edge, "source_id", None) or getattr(edge, "source", None)
tgt = getattr(edge, "target_id", None) or getattr(edge, "target", None)
if src and tgt:
src, tgt = str(src), str(tgt)
if tgt not in adjacency[src]:
adjacency[src].append(tgt)
if src not in adjacency[tgt]:
adjacency[tgt].append(src)
return dict(adjacency)
# Build adjacency
for rel in relationships:
+2 -2
View File
@@ -302,10 +302,10 @@ class TextCleaner:
# Remove potential script tags
text = re.sub(
r"<script[^>]*>.*?</script>", "", text, flags=re.IGNORECASE | re.DOTALL
r"<script[^>]*>.*?</script(?:\s[^>]*)?>", "", text, flags=re.IGNORECASE | re.DOTALL
)
text = re.sub(
r"<iframe[^>]*>.*?</iframe>", "", text, flags=re.IGNORECASE | re.DOTALL
r"<iframe[^>]*>.*?</iframe(?:\s[^>]*)?>", "", text, flags=re.IGNORECASE | re.DOTALL
)
# Remove javascript: URLs
+18 -1
View File
@@ -146,11 +146,21 @@ from .ontology_documentation import OntologyDocumentation, OntologyDocumentation
from .ontology_evaluator import EvaluationResult, OntologyEvaluator
from .ontology_generator import (
ClassInferencer,
NodeShape,
OntologyGenerator,
OntologyOptimizer,
PropertyInferencer,
PropertyShape,
SHACLGenerator,
SHACLGraph,
)
from .ontology_validator import (
OntologyValidator,
SHACLValidationReport,
SHACLViolation,
ValidationResult,
validate_ontology,
)
from .ontology_validator import OntologyValidator, ValidationResult, validate_ontology
from .owl_generator import OWLGenerator
from .property_generator import PropertyGenerator
from .registry import MethodRegistry, method_registry
@@ -175,6 +185,13 @@ __all__ = [
"validate_ontology",
"OntologyEvaluator",
"EvaluationResult",
# SHACL generation and validation
"SHACLGenerator",
"SHACLGraph",
"NodeShape",
"PropertyShape",
"SHACLValidationReport",
"SHACLViolation",
# OWL/RDF generation
"OWLGenerator",
# Requirements and competency questions
+384
View File
@@ -191,6 +191,390 @@ class OntologyEngine:
self.logger.error(f"Failed to list alignments: {e}")
raise ProcessingError(f"Failed to list alignments: {e}")
# ── SHACL Phase 1: Generation ─────────────────────────────────────────────
def to_shacl(
self,
ontology: Dict[str, Any],
*,
format: str = "turtle",
base_uri: Optional[str] = None,
shapes_uri: Optional[str] = None,
include_inherited: bool = True,
severity: str = "Violation",
quality_tier: str = "standard",
validate_output: bool = False,
**options,
) -> str:
"""
Auto-derive SHACL node shapes and property shapes from a Semantica ontology dict.
Args:
ontology: Ontology dict from any OntologyEngine generation method.
format: Output format "turtle" (default), "json-ld", or "n-triples".
base_uri: Base URI for generated shape URIs (inferred from ontology if omitted).
shapes_uri: URI for the shapes graph declaration.
include_inherited: Propagate parent class property shapes to child shapes.
severity: Default severity "Violation", "Warning", or "Info".
quality_tier: Constraint completeness "basic", "standard" (default), "strict".
validate_output: Syntax-check output via rdflib before returning.
Returns:
Serialized SHACL shapes string.
"""
from .ontology_generator import SHACLGenerator
tracking_id = self.progress.start_tracking(
module="ontology",
submodule="OntologyEngine",
message="Generating SHACL shapes",
)
try:
ns = ontology.get("namespace", {}) if isinstance(ontology, dict) else {}
resolved_base = (
base_uri
or (ns.get("base_uri") if isinstance(ns, dict) else None)
or "https://semantica.dev/shapes/"
)
generator = SHACLGenerator(
base_uri=resolved_base,
shapes_uri=shapes_uri,
include_inherited=include_inherited,
severity=severity,
quality_tier=quality_tier,
)
graph = generator.generate(ontology, **options)
self.progress.update_tracking(tracking_id, message="Serializing SHACL graph")
result = generator.serialize(graph, format=format)
if validate_output:
try:
import rdflib
_fmt_map = {
"turtle": "turtle", "ttl": "turtle",
"json-ld": "json-ld", "jsonld": "json-ld", "json_ld": "json-ld",
"n-triples": "nt", "ntriples": "nt", "nt": "nt",
}
rdflib_fmt = _fmt_map.get(format.lower().strip(), format)
g = rdflib.Graph()
g.parse(data=result, format=rdflib_fmt)
except Exception as e:
self.logger.warning(f"SHACL output syntax check failed: {e}")
self.progress.stop_tracking(
tracking_id, status="completed", message="SHACL generation complete"
)
return result
except Exception as e:
self.progress.stop_tracking(tracking_id, status="failed", message=str(e))
raise
def export_shacl(
self,
ontology: Dict[str, Any],
path,
format: str = "turtle",
encoding: str = "utf-8",
**options,
) -> None:
"""
Generate SHACL shapes from ontology and write to a file.
Args:
ontology: Ontology dict.
path: Output file path (str or Path). Parent directories are created if needed.
format: Output format "turtle", "json-ld", or "n-triples".
encoding: File encoding (default "utf-8").
"""
from pathlib import Path
shacl_str = self.to_shacl(ontology, format=format, **options)
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(shacl_str, encoding=encoding)
self.logger.info(f"SHACL shapes exported to {path}")
# ── SHACL Phase 2: Runtime Validation ────────────────────────────────────
def validate_graph(
self,
data_graph,
shacl=None,
*,
ontology: Optional[Dict[str, Any]] = None,
data_graph_format: str = "turtle",
shacl_format: str = "turtle",
explain: bool = True,
abort_on_first: bool = False,
**options,
):
"""
Validate a data graph against SHACL shapes.
Args:
data_graph: The graph to validate RDF string or rdflib.Graph.
shacl: Pre-built SHACL string or file Path (mutually exclusive with ontology).
ontology: Ontology dict SHACL is auto-generated before validation
(mutually exclusive with shacl).
data_graph_format: RDF format of data_graph when passed as a string.
shacl_format: RDF format of the shacl argument when it is a string or file
"turtle" (default), "json-ld", or "n-triples". Ignored when
ontology is provided (auto-generated shapes are always Turtle).
explain: Populate plain-English explanation on each violation.
abort_on_first: Stop after the first violation.
Returns:
SHACLValidationReport with structured violations and optional explanations.
Raises:
ValueError: If both or neither of shacl/ontology are provided.
ImportError: If pyshacl is not installed.
"""
from .ontology_validator import _run_pyshacl
if (shacl is None) == (ontology is None):
raise ValueError(
"Exactly one of 'shacl' or 'ontology' must be provided, not both or neither."
)
tracking_id = self.progress.start_tracking(
module="ontology",
submodule="OntologyEngine",
message="Preparing graph validation",
)
try:
if ontology is not None:
self.progress.update_tracking(
tracking_id, message="Generating SHACL from ontology"
)
shacl_str = self.to_shacl(ontology, **options)
shacl_format = "turtle" # auto-generated shapes are always Turtle
else:
import os
from pathlib import Path
if isinstance(shacl, Path) or (
isinstance(shacl, str) and os.path.exists(shacl)
):
shacl_str = Path(shacl).read_text(encoding="utf-8")
else:
shacl_str = str(shacl)
if isinstance(data_graph, str):
data_graph_str = data_graph
else:
data_graph_str = data_graph.serialize(format=data_graph_format)
self.progress.update_tracking(tracking_id, message="Running pyshacl validator")
report = _run_pyshacl(
data_graph_str,
shacl_str,
data_graph_format=data_graph_format,
shacl_format=shacl_format,
)
if explain:
self.progress.update_tracking(
tracking_id, message="Generating violation explanations"
)
report.explain_violations()
self.progress.stop_tracking(
tracking_id, status="completed", message="Validation complete"
)
return report
except Exception as e:
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):
return self.evaluator.evaluate_ontology(ontology, **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.
+1 -1
View File
@@ -350,7 +350,7 @@ class NamingConventions:
def _is_noun_phrase(self, name: str) -> bool:
"""Check if name is a noun phrase (basic heuristic)."""
# Basic heuristic: PascalCase words are typically nouns
return bool(re.match(r"^[A-Z][a-zA-Z0-9]*([A-Z][a-zA-Z0-9]*)*$", name))
return bool(name and name[0].isupper() and re.match(r"^[A-Za-z0-9]+$", name))
def _is_verb_phrase(self, name: str) -> bool:
"""Check if name is a verb phrase (basic heuristic)."""
+494
View File
@@ -30,6 +30,7 @@ Author: Semantica Contributors
License: MIT
"""
from dataclasses import dataclass, field, replace as dataclass_replace
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -709,3 +710,496 @@ class OntologyOptimizer:
prop["range"] = ["owl:Thing"]
return ontology
# ─────────────────────────────────────────────────────────────────────────────
# SHACL Shape Generation
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class PropertyShape:
"""Internal model for a SHACL sh:PropertyShape."""
path: str
name: Optional[str] = None
description: Optional[str] = None
datatype: Optional[str] = None # sh:datatype
class_: Optional[str] = None # sh:class
min_count: Optional[int] = None
max_count: Optional[int] = None
in_values: Optional[List[str]] = None
has_value: Optional[str] = None
pattern: Optional[str] = None
severity: str = "Violation"
@dataclass
class NodeShape:
"""Internal model for a SHACL sh:NodeShape."""
target_class: str
name: Optional[str] = None
description: Optional[str] = None
property_shapes: List[PropertyShape] = field(default_factory=list)
closed: bool = False
severity: str = "Violation"
@dataclass
class SHACLGraph:
"""Internal model representing the complete SHACL shapes graph."""
base_uri: str
shapes_uri: str
node_shapes: List[NodeShape] = field(default_factory=list)
prefixes: Dict[str, str] = field(default_factory=dict)
class SHACLGenerator:
"""
Generates SHACL shapes from Semantica OWL ontology dicts.
6-stage internal pipeline:
1. _build_class_index() {class_name: class_dict} for O(1) lookup
2. _generate_node_shapes() one NodeShape per OWL class
3. _attach_property_shapes() map properties to their domain node shapes
4. _propagate_inheritance() copy parent shapes to children (iterative, cycle-safe)
5. _apply_quality_tier() strict tier: set closed=True on all shapes
6. serialize() Turtle / JSON-LD / N-Triples
"""
_XSD_ALIASES: Dict[str, str] = {
"string": "xsd:string", "str": "xsd:string",
"int": "xsd:integer", "integer": "xsd:integer",
"float": "xsd:decimal", "decimal": "xsd:decimal",
"boolean": "xsd:boolean", "bool": "xsd:boolean",
"date": "xsd:date",
"datetime": "xsd:dateTime",
"uri": "xsd:anyURI", "anyuri": "xsd:anyURI",
}
def __init__(
self,
base_uri: str = "https://semantica.dev/shapes/",
shapes_uri: Optional[str] = None,
include_inherited: bool = True,
severity: str = "Violation",
quality_tier: str = "standard",
config: Optional[Dict[str, Any]] = None,
):
self.logger = get_logger("ontology_shacl")
self.progress_tracker = get_progress_tracker()
self.base_uri = base_uri.rstrip("/") + "/"
self.shapes_uri = shapes_uri or (self.base_uri + "shapes")
self.include_inherited = include_inherited
self.severity = severity
self.quality_tier = quality_tier
self.config = config or {}
# ── Public API ────────────────────────────────────────────────────────────
def generate(self, ontology: Dict[str, Any], **options) -> SHACLGraph:
"""Generate a SHACLGraph from a Semantica ontology dict."""
if not isinstance(ontology, dict):
raise ValueError("ontology must be a dict")
if "classes" not in ontology and "properties" not in ontology:
raise ValueError(
"ontology must contain at least a 'classes' or 'properties' key"
)
tracking_id = self.progress_tracker.start_tracking(
module="ontology", submodule="SHACLGenerator", message="Building SHACL index"
)
try:
classes = ontology.get("classes", [])
properties = ontology.get("properties", [])
# Resolve base_uri from ontology namespace if present
ns = ontology.get("namespace", {})
base_uri = (
ns.get("base_uri", self.base_uri) if isinstance(ns, dict) else self.base_uri
)
if not base_uri.endswith("/") and not base_uri.endswith("#"):
base_uri += "/"
prefixes = {
"sh": "http://www.w3.org/ns/shacl#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"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#",
"ex": base_uri,
}
graph = SHACLGraph(
base_uri=base_uri,
shapes_uri=self.shapes_uri,
prefixes=prefixes,
)
self.progress_tracker.update_tracking(tracking_id, message="Generating node shapes")
class_index = self._build_class_index(classes)
self._generate_node_shapes(graph, classes)
self.progress_tracker.update_tracking(tracking_id, message="Attaching property shapes")
self._attach_property_shapes(graph, properties)
if self.include_inherited:
self.progress_tracker.update_tracking(tracking_id, message="Propagating inheritance")
self._propagate_inheritance(graph, class_index)
self._apply_quality_tier(graph)
self.progress_tracker.stop_tracking(
tracking_id, status="completed", message="SHACL graph built"
)
return graph
except (ValueError, TypeError):
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message="Generation failed"
)
raise
except Exception as exc:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(exc)
)
from ..utils.exceptions import ProcessingError
raise ProcessingError(f"SHACL generation failed: {exc}") from exc
def serialize(self, graph: SHACLGraph, format: str = "turtle") -> str:
"""Serialize a SHACLGraph to a string in the requested format."""
tracking_id = self.progress_tracker.start_tracking(
module="ontology", submodule="SHACLGenerator", message="Serializing SHACL graph"
)
try:
fmt = format.lower().strip()
if fmt in ("turtle", "ttl"):
result = self._serialize_turtle(graph)
elif fmt in ("json-ld", "jsonld", "json_ld"):
result = self._serialize_jsonld(graph)
elif fmt in ("n-triples", "ntriples", "nt"):
result = self._serialize_ntriples(graph)
else:
raise ValueError(
f"Unsupported SHACL serialization format: '{format}'. "
"Supported formats: 'turtle', 'json-ld', 'n-triples'"
)
self.progress_tracker.stop_tracking(
tracking_id, status="completed", message="Serialized"
)
return result
except ValueError:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message="Unsupported format"
)
raise
except Exception as exc:
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(exc)
)
from ..utils.exceptions import ProcessingError
raise ProcessingError(f"SHACL serialization failed: {exc}") from exc
# ── Internal pipeline stages ──────────────────────────────────────────────
def _build_class_index(
self, classes: List[Dict[str, Any]]
) -> Dict[str, Dict[str, Any]]:
return {c["name"]: c for c in classes if c.get("name")}
def _generate_node_shapes(
self, graph: SHACLGraph, classes: List[Dict[str, Any]]
) -> None:
for cls in classes:
name = cls.get("name")
if not name:
continue
shape = NodeShape(
target_class=name,
name=cls.get("label") or cls.get("name"),
description=cls.get("description") or cls.get("comment"),
severity=self.severity,
)
graph.node_shapes.append(shape)
def _attach_property_shapes(
self, graph: SHACLGraph, properties: List[Dict[str, Any]]
) -> None:
shape_by_class = {ns.target_class: ns for ns in graph.node_shapes}
for prop in properties:
pname = prop.get("name")
if not pname:
continue
domain = prop.get("domain")
if isinstance(domain, list):
domains = [d for d in domain if d]
elif isinstance(domain, str) and domain:
domains = [domain]
else:
domains = []
if domains:
for d in domains:
if d in shape_by_class:
shape_by_class[d].property_shapes.append(
self._build_property_shape(prop)
)
else:
self.logger.debug(
f"Property '{pname}' domain '{d}' has no matching node shape — skipped"
)
else:
# No domain declared → attach to all shapes
self.logger.debug(
f"Property '{pname}' has no domain — attaching to all node shapes"
)
for node_shape in graph.node_shapes:
node_shape.property_shapes.append(self._build_property_shape(prop))
def _build_property_shape(self, prop: Dict[str, Any]) -> PropertyShape:
ptype = prop.get("type", "")
range_ = prop.get("range", "")
if isinstance(range_, list):
range_ = range_[0] if range_ else ""
cardinality = prop.get("cardinality") or {}
min_count = cardinality.get("min") if isinstance(cardinality, dict) else None
max_count = cardinality.get("max") if isinstance(cardinality, dict) else None
if prop.get("required") and min_count is None:
min_count = 1
datatype = None
class_ = None
if ptype in ("datatype", "data", "DatatypeProperty"):
datatype = self._resolve_xsd(range_) if range_ else None
elif ptype in ("object", "ObjectProperty"):
class_ = range_ if range_ else None
in_values = (
prop.get("one_of") or prop.get("enum") or prop.get("allowed_values")
)
if in_values and self.quality_tier in ("standard", "strict"):
in_values = list(in_values)
else:
in_values = None
pattern = prop.get("pattern") if self.quality_tier in ("standard", "strict") else None
return PropertyShape(
path=prop.get("name", ""),
name=prop.get("label") or prop.get("name"),
description=prop.get("description") or prop.get("comment"),
datatype=datatype,
class_=class_,
min_count=min_count,
max_count=max_count,
in_values=in_values,
has_value=prop.get("has_value"),
pattern=pattern,
severity=self.severity,
)
def _propagate_inheritance(
self, graph: SHACLGraph, class_index: Dict[str, Dict[str, Any]]
) -> None:
shape_by_class = {ns.target_class: ns for ns in graph.node_shapes}
for _ in range(20): # max 20 passes; stops early when stable
changed = False
for node_shape in graph.node_shapes:
cls_data = class_index.get(node_shape.target_class, {})
parent_name = cls_data.get("parent") or cls_data.get("parent_class")
if not parent_name or parent_name not in shape_by_class:
continue
parent_shape = shape_by_class[parent_name]
existing_paths = {ps.path for ps in node_shape.property_shapes}
for pps in parent_shape.property_shapes:
if pps.path not in existing_paths:
node_shape.property_shapes.append(dataclass_replace(pps))
existing_paths.add(pps.path)
changed = True
if not changed:
break
def _apply_quality_tier(self, graph: SHACLGraph) -> None:
if self.quality_tier == "strict":
for node_shape in graph.node_shapes:
# Only close shapes that declare at least one property
if node_shape.property_shapes:
node_shape.closed = True
# ── Serializers ───────────────────────────────────────────────────────────
def _prefix_decls(self, graph: SHACLGraph) -> str:
return "\n".join(f"@prefix {p}: <{u}> ." for p, u in sorted(graph.prefixes.items()))
def _uri(self, graph: SHACLGraph, local: str) -> str:
"""Return a compact URI reference; fall back to ex:local for bare names."""
if local.startswith("http://") or local.startswith("https://"):
return f"<{local}>"
if ":" in local:
return local
return f"ex:{local}"
def _serialize_turtle(self, graph: SHACLGraph) -> str:
lines = [self._prefix_decls(graph), ""]
lines.append(f"<{graph.shapes_uri}> a owl:Ontology .")
lines.append("")
for node_shape in graph.node_shapes:
shape_uri = f"{graph.base_uri}{node_shape.target_class}Shape"
block = [f"<{shape_uri}>"]
block.append(" a sh:NodeShape ;")
block.append(
f" sh:targetClass {self._uri(graph, node_shape.target_class)} ;"
)
if node_shape.name:
block.append(f' sh:name "{node_shape.name}" ;')
if node_shape.description:
escaped = node_shape.description.replace('"', '\\"')
block.append(f' sh:description "{escaped}" ;')
if node_shape.closed:
block.append(" sh:closed true ;")
block.append(" sh:ignoredProperties ( <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> ) ;")
for i, ps in enumerate(node_shape.property_shapes):
is_last = i == len(node_shape.property_shapes) - 1
terminator = " ." if is_last else " ;"
parts = [" sh:property ["]
parts.append(f" sh:path {self._uri(graph, ps.path)} ;")
if ps.datatype:
parts.append(f" sh:datatype {ps.datatype} ;")
if ps.class_:
parts.append(f" sh:class {self._uri(graph, ps.class_)} ;")
if ps.min_count is not None:
parts.append(f" sh:minCount {ps.min_count} ;")
if ps.max_count is not None:
parts.append(f" sh:maxCount {ps.max_count} ;")
if ps.in_values is not None:
vals = " ".join(f'"{v}"' for v in ps.in_values)
parts.append(f" sh:in ( {vals} ) ;")
if ps.has_value is not None:
parts.append(f" sh:hasValue {self._uri(graph, ps.has_value)} ;")
if ps.pattern:
escaped_p = ps.pattern.replace('"', '\\"')
parts.append(f' sh:pattern "{escaped_p}" ;')
parts.append(f" sh:severity sh:{ps.severity}")
parts.append(" ]" + terminator)
block.extend(parts)
if not node_shape.property_shapes:
# Close the declaration when there are no property shapes
block[-1] = block[-1].rstrip(" ;") + " ."
lines.append("\n".join(block))
lines.append("")
return "\n".join(lines)
def _serialize_jsonld(self, graph: SHACLGraph) -> str:
import json
context: Dict[str, Any] = dict(graph.prefixes)
context["sh"] = "http://www.w3.org/ns/shacl#"
context["@vocab"] = graph.base_uri
graph_list: List[Dict[str, Any]] = [
{"@id": graph.shapes_uri, "@type": "owl:Ontology"}
]
for node_shape in graph.node_shapes:
shape_id = f"{graph.base_uri}{node_shape.target_class}Shape"
node: Dict[str, Any] = {
"@id": shape_id,
"@type": "sh:NodeShape",
"sh:targetClass": {"@id": f"{graph.base_uri}{node_shape.target_class}"},
}
if node_shape.name:
node["sh:name"] = node_shape.name
if node_shape.description:
node["sh:description"] = node_shape.description
if node_shape.closed:
node["sh:closed"] = True
node["sh:ignoredProperties"] = [{"@id": "rdf:type"}]
if node_shape.property_shapes:
props = []
for ps in node_shape.property_shapes:
p: Dict[str, Any] = {
"sh:path": {"@id": f"{graph.base_uri}{ps.path}"}
}
if ps.datatype:
dt = ps.datatype.replace(
"xsd:", "http://www.w3.org/2001/XMLSchema#"
)
p["sh:datatype"] = {"@id": dt}
if ps.class_:
p["sh:class"] = {"@id": f"{graph.base_uri}{ps.class_}"}
if ps.min_count is not None:
p["sh:minCount"] = ps.min_count
if ps.max_count is not None:
p["sh:maxCount"] = ps.max_count
if ps.in_values:
p["sh:in"] = {"@list": ps.in_values}
if ps.has_value is not None:
p["sh:hasValue"] = ps.has_value
if ps.pattern:
p["sh:pattern"] = ps.pattern
p["sh:severity"] = {"@id": f"sh:{ps.severity}"}
props.append(p)
node["sh:property"] = props
graph_list.append(node)
return json.dumps({"@context": context, "@graph": graph_list}, indent=2)
def _serialize_ntriples(self, graph: SHACLGraph) -> str:
SHACL = "http://www.w3.org/ns/shacl#"
OWL = "http://www.w3.org/2002/07/owl#"
RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
XSD = "http://www.w3.org/2001/XMLSchema#"
lines: List[str] = []
def t(s: str, p: str, o: str) -> None:
lines.append(f"{s} {p} {o} .")
t(f"<{graph.shapes_uri}>", f"<{RDF}type>", f"<{OWL}Ontology>")
for i, node_shape in enumerate(graph.node_shapes):
shape_uri = f"<{graph.base_uri}{node_shape.target_class}Shape>"
class_uri = f"<{graph.base_uri}{node_shape.target_class}>"
t(shape_uri, f"<{RDF}type>", f"<{SHACL}NodeShape>")
t(shape_uri, f"<{SHACL}targetClass>", class_uri)
if node_shape.name:
t(shape_uri, f"<{SHACL}name>", f'"{node_shape.name}"')
if node_shape.closed:
t(
shape_uri,
f"<{SHACL}closed>",
f'"true"^^<{XSD}boolean>',
)
for j, ps in enumerate(node_shape.property_shapes):
bnode = f"_:ps{i}_{j}"
t(shape_uri, f"<{SHACL}property>", bnode)
prop_uri = f"<{graph.base_uri}{ps.path}>"
t(bnode, f"<{SHACL}path>", prop_uri)
if ps.datatype:
dt_uri = ps.datatype.replace("xsd:", XSD)
t(bnode, f"<{SHACL}datatype>", f"<{dt_uri}>")
if ps.class_:
t(bnode, f"<{SHACL}class>", f"<{graph.base_uri}{ps.class_}>")
if ps.min_count is not None:
t(bnode, f"<{SHACL}minCount>", f'"{ps.min_count}"^^<{XSD}integer>')
if ps.max_count is not None:
t(bnode, f"<{SHACL}maxCount>", f'"{ps.max_count}"^^<{XSD}integer>')
t(bnode, f"<{SHACL}severity>", f"<{SHACL}{ps.severity}>")
return "\n".join(lines)
# ── Helper ────────────────────────────────────────────────────────────────
def _resolve_xsd(self, range_str: str) -> str:
"""Map ontology range strings to xsd:-prefixed datatypes."""
key = range_str.lower().strip()
return self._XSD_ALIASES.get(key, f"xsd:{range_str}")
+216
View File
@@ -16,6 +16,222 @@ from dataclasses import dataclass, field
from ..utils.logging import get_logger
# ─────────────────────────────────────────────────────────────────────────────
# SHACL Validation Models
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class SHACLViolation:
"""Represents a single SHACL constraint violation."""
focus_node: str
result_path: Optional[str] = None
constraint: str = ""
severity: str = "Violation"
message: Optional[str] = None
value: Optional[str] = None
shape: Optional[str] = None
explanation: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"focus_node": self.focus_node,
"result_path": self.result_path,
"constraint": self.constraint,
"severity": self.severity,
"message": self.message,
"value": self.value,
"shape": self.shape,
"explanation": self.explanation,
}
@dataclass
class SHACLValidationReport:
"""Structured SHACL validation report with machine-readable violations and explanations."""
conforms: bool
violations: List[SHACLViolation] = field(default_factory=list)
warnings: List[SHACLViolation] = field(default_factory=list)
infos: List[SHACLViolation] = field(default_factory=list)
raw_report: Optional[str] = None
@property
def violation_count(self) -> int:
return len(self.violations)
@property
def warning_count(self) -> int:
return len(self.warnings)
def summary(self) -> str:
if self.conforms:
return "Graph conforms to all SHACL constraints."
return f"Graph does NOT conform: {self.violation_count} violation(s)."
def explain_violations(self) -> None:
"""Populate a plain-English explanation on every violation. No LLM call."""
_TEMPLATES = {
"MinCountConstraintComponent": (
"Node <{focus_node}> is missing required property <{path}>. "
"At least {min_count} value(s) are required."
),
"MaxCountConstraintComponent": (
"Node <{focus_node}> has too many values for <{path}>. "
"At most {max_count} value(s) are allowed."
),
"DatatypeConstraintComponent": (
"Node <{focus_node}> has value '{value}' for <{path}> "
"but the expected datatype is {datatype}."
),
"ClassConstraintComponent": (
"Node <{focus_node}> has value '{value}' for <{path}> "
"but it must be an instance of {class_}."
),
"InConstraintComponent": (
"Node <{focus_node}> has value '{value}' for <{path}> "
"which is not in the allowed set."
),
"PatternConstraintComponent": (
"Node <{focus_node}> has value '{value}' for <{path}> "
"which does not match the required pattern."
),
"ClosedConstraintComponent": (
"Node <{focus_node}> has undeclared property <{path}> "
"which is not allowed by the closed shape."
),
}
for v in self.violations + self.warnings + self.infos:
tmpl = None
for key, tpl in _TEMPLATES.items():
if key in (v.constraint or ""):
tmpl = tpl
break
if tmpl is None:
v.explanation = (
f"Node <{v.focus_node}> failed constraint "
f"{v.constraint or '(unknown)'}"
+ (f" on property <{v.result_path}>." if v.result_path else ".")
)
continue
v.explanation = tmpl.format(
focus_node=v.focus_node,
path=v.result_path or "",
value=v.value or "",
min_count=1,
max_count=1,
datatype=v.message or "",
class_=v.message or "",
)
def to_dict(self) -> Dict[str, Any]:
return {
"conforms": self.conforms,
"violation_count": self.violation_count,
"warning_count": self.warning_count,
"violations": [v.to_dict() for v in self.violations],
"warnings": [v.to_dict() for v in self.warnings],
"infos": [v.to_dict() for v in self.infos],
}
def _run_pyshacl(
data_graph_str: str,
shacl_str: str,
data_graph_format: str = "turtle",
shacl_format: str = "turtle",
) -> SHACLValidationReport:
"""
Run pyshacl validation and return a structured SHACLValidationReport.
Args:
data_graph_str: Serialized data graph string.
shacl_str: Serialized SHACL shapes string.
data_graph_format: RDF format of data_graph_str (default "turtle").
shacl_format: RDF format of shacl_str "turtle", "json-ld", or "nt"
(default "turtle").
Raises ImportError if pyshacl or rdflib are not installed
(install with: pip install semantica[shacl]).
"""
try:
import pyshacl
except ImportError as exc:
raise ImportError(
"pyshacl is required for SHACL validation. "
"Install it with: pip install semantica[shacl]"
) from exc
try:
import rdflib
except ImportError as exc:
raise ImportError(
"rdflib is required for SHACL validation. "
"Install it with: pip install rdflib"
) from exc
data_g = rdflib.Graph()
data_g.parse(data=data_graph_str, format=data_graph_format)
_fmt_map = {
"turtle": "turtle", "ttl": "turtle",
"json-ld": "json-ld", "jsonld": "json-ld", "json_ld": "json-ld",
"n-triples": "nt", "ntriples": "nt", "nt": "nt",
}
shacl_g = rdflib.Graph()
shacl_g.parse(data=shacl_str, format=_fmt_map.get(shacl_format.lower().strip(), shacl_format))
conforms, results_graph, results_text = pyshacl.validate(
data_g,
shacl_graph=shacl_g,
inference="none",
abort_on_first=False,
)
violations: List[SHACLViolation] = []
warnings: List[SHACLViolation] = []
infos: List[SHACLViolation] = []
SH = rdflib.Namespace("http://www.w3.org/ns/shacl#")
for result in results_graph.subjects(rdflib.RDF.type, SH.ValidationResult):
focus = str(results_graph.value(result, SH.focusNode) or "")
path_node = results_graph.value(result, SH.resultPath)
path = str(path_node) if path_node is not None else None
sev_node = results_graph.value(result, SH.resultSeverity)
sev_str = str(sev_node).split("#")[-1] if sev_node is not None else "Violation"
msg_node = results_graph.value(result, SH.resultMessage)
msg = str(msg_node) if msg_node is not None else None
val_node = results_graph.value(result, SH.value)
val = str(val_node) if val_node is not None else None
src_node = results_graph.value(result, SH.sourceConstraintComponent)
constraint = str(src_node).split("#")[-1] if src_node is not None else ""
shape_node = results_graph.value(result, SH.sourceShape)
shape = str(shape_node) if shape_node is not None else None
v = SHACLViolation(
focus_node=focus,
result_path=path,
constraint=constraint,
severity=sev_str,
message=msg,
value=val,
shape=shape,
)
if sev_str == "Violation":
violations.append(v)
elif sev_str == "Warning":
warnings.append(v)
else:
infos.append(v)
return SHACLValidationReport(
conforms=conforms,
violations=violations,
warnings=warnings,
infos=infos,
raw_report=results_text,
)
@dataclass
class ValidationResult:
"""Result of an ontology validation operation."""
@@ -443,12 +443,6 @@ class RelationExtractor:
if verbose_mode and method_name == "llm":
import sys
print(f" [RelationExtractor] Processing with {method_name}...", flush=True, file=sys.stdout)
print(f" [RelationExtractor Debug] method_options keys: {list(method_options.keys())}", flush=True, file=sys.stdout)
if "api_key" in method_options:
masked = method_options["api_key"][:4] + "..." if method_options["api_key"] else "None"
print(f" [RelationExtractor Debug] api_key present: {masked}", flush=True, file=sys.stdout)
else:
print(f" [RelationExtractor Debug] api_key NOT present", flush=True, file=sys.stdout)
relations = method_func(text, entities, **method_options)
@@ -494,11 +494,6 @@ class TripletExtractor:
if verbose_mode and method_name == "llm":
import sys
print(f" [TripletExtractor] Processing with {method_name}...", flush=True, file=sys.stdout)
if "api_key" in method_options:
masked = method_options["api_key"][:4] + "..." if method_options["api_key"] else "None"
print(f" [TripletExtractor Debug] api_key present: {masked}", flush=True, file=sys.stdout)
else:
print(f" [TripletExtractor Debug] api_key NOT present", flush=True, file=sys.stdout)
triplets = method_func(
text,
+41 -1
View File
@@ -5,6 +5,7 @@ This module provides the REST API server for the Semantica framework
using FastAPI and uvicorn.
"""
import logging
import uvicorn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
@@ -53,9 +54,48 @@ async def build_kb(request: BuildRequest):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Explorer API Routers (Loaded gracefully if semantica[explorer] is installed)
try:
from .explorer.routes import (
analytics,
annotations,
decisions,
enrich,
export_import,
graph,
temporal,
)
app.include_router(analytics.router)
app.include_router(annotations.router)
app.include_router(decisions.router)
app.include_router(enrich.router)
app.include_router(export_import.router)
app.include_router(graph.router)
app.include_router(temporal.router)
logging.info("Explorer API routes successfully mounted.")
except ImportError as exc:
logging.warning(
f"Explorer API routes not mounted. To enable the Knowledge Explorer, "
f"install the required dependencies: pip install semantica[explorer]. "
f"Details: {exc}"
)
# Vocabulary router — mounted separately; available once PR #421 lands
try:
from .explorer.routes import vocabulary
app.include_router(vocabulary.router)
logging.info("Vocabulary API routes successfully mounted.")
except ImportError:
logging.debug("Vocabulary router not yet available (pending implementation).")
def main():
"""Server entry point."""
uvicorn.run(app, host="0.0.0.0", port=8000)
if __name__ == "__main__":
main()
main()
+21
View File
@@ -109,10 +109,14 @@ class TripletStoreConfig:
"""Load configuration from environment variables."""
env_mappings = {
"TRIPLET_STORE_DEFAULT_STORE": "default_store",
"TRIPLET_STORE_DEFAULT_GRAPH": "default_graph",
"TRIPLET_STORE_DEFAULT_GRAPH_URI": "default_graph_uri",
"TRIPLET_STORE_DEFAULT_NAMED_GRAPHS": "default_graphs",
"TRIPLET_STORE_BATCH_SIZE": "batch_size",
"TRIPLET_STORE_ENABLE_CACHING": "enable_caching",
"TRIPLET_STORE_CACHE_SIZE": "cache_size",
"TRIPLET_STORE_ENABLE_OPTIMIZATION": "enable_optimization",
"TRIPLET_STORE_ENABLE_NAMED_GRAPHS": "enable_named_graphs",
"TRIPLET_STORE_MAX_RETRIES": "max_retries",
"TRIPLET_STORE_RETRY_DELAY": "retry_delay",
"TRIPLET_STORE_TIMEOUT": "timeout",
@@ -139,6 +143,19 @@ class TripletStoreConfig:
"yes",
"on",
]
elif config_key == "enable_named_graphs":
self._config[config_key] = value.lower() in [
"true",
"1",
"yes",
"on",
]
elif config_key == "default_graphs":
self._config[config_key] = [
graph_uri.strip()
for graph_uri in value.split(",")
if graph_uri.strip()
]
elif config_key == "retry_delay":
try:
self._config[config_key] = float(value)
@@ -153,10 +170,14 @@ class TripletStoreConfig:
"""Set default configuration values."""
defaults = {
"default_store": None,
"default_graph": None,
"default_graph_uri": None,
"default_graphs": [],
"batch_size": 1000,
"enable_caching": True,
"cache_size": 1000,
"enable_optimization": True,
"enable_named_graphs": True,
"max_retries": 3,
"retry_delay": 1.0,
"timeout": 30,
+107 -7
View File
@@ -31,6 +31,7 @@ License: MIT
"""
import time
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -120,11 +121,22 @@ class QueryEngine:
try:
start_time = time.time()
supports_named_graphs = options.get("supports_named_graphs")
if supports_named_graphs is None:
supports_named_graphs = getattr(store_backend, "supports_named_graphs", True)
prepared_query = self.prepare_query(
query,
graph=options.get("graph"),
graphs=options.get("graphs"),
supports_named_graphs=supports_named_graphs,
)
# Validate query
self.progress_tracker.update_tracking(
tracking_id, message="Validating query..."
)
if not self._validate_query(query):
if not self._validate_query(prepared_query):
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message="Invalid SPARQL query"
)
@@ -135,7 +147,7 @@ class QueryEngine:
self.progress_tracker.update_tracking(
tracking_id, message="Checking cache..."
)
cache_key = self._get_cache_key(query)
cache_key = self._get_cache_key(prepared_query)
if cache_key in self.query_cache:
self.logger.debug("Returning cached query result")
cached_result = self.query_cache[cache_key]
@@ -152,9 +164,9 @@ class QueryEngine:
self.progress_tracker.update_tracking(
tracking_id, message="Optimizing query..."
)
optimized_query = self.optimize_query(query, **options)
optimized_query = self.optimize_query(prepared_query, **options)
else:
optimized_query = query
optimized_query = prepared_query
# Execute query
self.progress_tracker.update_tracking(
@@ -173,8 +185,10 @@ class QueryEngine:
execution_time=execution_time,
metadata={
**result_data.get("metadata", {}),
"optimized": optimized_query != query,
"optimized": optimized_query != prepared_query,
"cached": False,
"graph": options.get("graph"),
"graphs": options.get("graphs") or [],
},
)
@@ -183,12 +197,12 @@ class QueryEngine:
self.progress_tracker.update_tracking(
tracking_id, message="Caching result..."
)
self._cache_result(query, result)
self._cache_result(prepared_query, result)
# Record history
self.query_history.append(
{
"query": query,
"query": prepared_query,
"execution_time": execution_time,
"result_count": len(result.bindings),
"timestamp": datetime.now().isoformat(),
@@ -212,6 +226,92 @@ class QueryEngine:
)
raise ProcessingError(f"Query execution failed: {e}")
def prepare_query(
self,
query: str,
graph: Optional[str] = None,
graphs: Optional[List[str]] = None,
supports_named_graphs: bool = True,
) -> str:
"""Prepare query with optional graph dataset clauses."""
if not query:
return ""
resolved_graph = (
graph
or self.config.get("default_graph")
or self.config.get("default_graph_uri")
)
resolved_graphs = graphs
if resolved_graphs is None:
resolved_graphs = self.config.get("default_graphs")
if isinstance(resolved_graphs, str):
resolved_graphs = [resolved_graphs]
resolved_graphs = [g for g in (resolved_graphs or []) if g]
if resolved_graph and resolved_graph in resolved_graphs:
# Preserve graph as default dataset while avoiding duplicate URIs in FROM NAMED.
resolved_graphs = [g for g in resolved_graphs if g != resolved_graph]
if not supports_named_graphs and (resolved_graph or resolved_graphs):
self.logger.warning(
"Named graph options were provided but backend does not support named graphs; "
"falling back to backend default dataset"
)
return query.strip()
return self._inject_graph_clauses(
query,
graph=resolved_graph,
graphs=resolved_graphs,
)
def _inject_graph_clauses(
self,
query: str,
graph: Optional[str] = None,
graphs: Optional[List[str]] = None,
) -> str:
"""Inject FROM/FROM NAMED clauses immediately before WHERE."""
normalized_query = query.strip()
graph_list = [g for g in (graphs or []) if g]
if not graph and not graph_list:
return normalized_query
if re.search(r"\bFROM\b", normalized_query, flags=re.IGNORECASE):
return normalized_query
if not re.search(
r"\b(SELECT|ASK|CONSTRUCT|DESCRIBE)\b",
normalized_query,
flags=re.IGNORECASE,
):
return normalized_query
where_match = re.search(r"\bWHERE\b", normalized_query, flags=re.IGNORECASE)
if not where_match:
return normalized_query
dataset_clauses: List[str] = []
if graph:
safe_graph = self._sanitize_uri(graph)
dataset_clauses.append(f"FROM <{safe_graph}>")
for graph_uri in graph_list:
safe_graph = self._sanitize_uri(graph_uri)
dataset_clauses.append(f"FROM NAMED <{safe_graph}>")
if not dataset_clauses:
return normalized_query
before_where = normalized_query[: where_match.start()].rstrip()
where_and_after = normalized_query[where_match.start() :].lstrip()
dataset_block = "\n".join(dataset_clauses)
return f"{before_where}\n{dataset_block}\n{where_and_after}"
def optimize_query(self, query: str, **options) -> str:
"""
Optimize SPARQL query.
+173 -2
View File
@@ -46,6 +46,7 @@ class TripletStore:
"""
SUPPORTED_BACKENDS = {"blazegraph", "jena", "rdf4j"}
NAMED_GRAPH_CAPABLE_BACKENDS = {"blazegraph", "rdf4j"}
def __init__(
self,
@@ -76,7 +77,7 @@ class TripletStore:
self.backend_type = backend.lower()
self.endpoint = endpoint
self.config = config
self.config = {**triplet_store_config.get_all(), **config}
# Initialize store backend
self._store_backend = None
@@ -393,7 +394,12 @@ class TripletStore:
return self.add_triplet(new_triplet, **options)
def execute_query(
self, query: str, parameters: Optional[Dict[str, Any]] = None, **options
self,
query: str,
parameters: Optional[Dict[str, Any]] = None,
graph: Optional[str] = None,
graphs: Optional[List[str]] = None,
**options,
) -> Any:
"""
Execute a SPARQL query.
@@ -401,11 +407,25 @@ class TripletStore:
Args:
query: SPARQL query string
parameters: Query parameters
graph: Optional default graph URI for dataset scoping
graphs: Optional list of named graph URIs for dataset scoping
**options: Additional options
Returns:
Query results (format depends on query type)
"""
if graph is not None:
options["graph"] = graph
if graphs is not None:
options["graphs"] = graphs
enable_named_graphs = self.config.get("enable_named_graphs", True)
options.setdefault(
"supports_named_graphs",
enable_named_graphs
and self.backend_type in self.NAMED_GRAPH_CAPABLE_BACKENDS,
)
return self.query_engine.execute_query(query, self._store_backend, **options)
def _validate_triplet(self, triplet: Triplet) -> bool:
@@ -422,6 +442,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"):
+36
View File
@@ -7,6 +7,7 @@ knowledge graphs and ontologies with comprehensive change tracking.
import os
import tempfile
from unittest.mock import MagicMock
import pytest
from semantica.change_management import (
TemporalVersionManager,
@@ -179,6 +180,41 @@ class TestTemporalVersionManager:
assert len(versions) == 1
assert versions[0]["entity_count"] == 2
assert versions[0]["relationship_count"] == 1
def test_prune_versions_sanitizes_graph_uri_in_drop_query(self):
"""Ensure DROP GRAPH query uses sanitized URI encoding for unsafe characters."""
manager = TemporalVersionManager()
triplet_store = MagicMock()
manager.storage.save(
{
"label": "old-v1",
"timestamp": "2024-01-01T00:00:00",
"author": "test@example.com",
"description": "old",
"checksum": "x",
"entities": [],
"relationships": [],
"graph_uri": "http://example.org/graph> } ; DROP ALL ; #",
}
)
manager.storage.save(
{
"label": "new-v2",
"timestamp": "2025-01-01T00:00:00",
"author": "test@example.com",
"description": "new",
"checksum": "y",
"entities": [],
"relationships": [],
"graph_uri": "http://example.org/graph/new",
}
)
manager.prune_versions(keep_last_n=1, triplet_store=triplet_store)
query = triplet_store.execute_query.call_args[0][0]
assert "DROP SILENT GRAPH <http://example.org/graph%3E%20%7D%20%3B%20DROP%20ALL%20%3B%20%23>" == query
def test_get_version(self):
"""Test retrieving specific version."""
+18
View File
@@ -39,6 +39,24 @@ def test_agent_context_minimal_decisions_and_chain():
assert len(chain) >= 1
def test_agent_context_inmemory_store_and_retrieve():
"""VectorStore(backend="inmemory") stores memories without faiss-cpu."""
vs = VectorStore(backend="inmemory")
ctx = AgentContext(
vector_store=vs,
knowledge_graph=ContextGraph(),
decision_tracking=True,
kg_algorithms=False,
vector_store_features=False,
)
memory_id = ctx.store(
"GPT-4 outperforms GPT-3.5 on reasoning benchmarks by 40%",
conversation_id="test_session",
)
assert isinstance(memory_id, str)
assert len(memory_id) > 0
def test_agent_context_policy_engine_with_graph_backend():
vs = VectorStore(backend="inmemory", dimension=64)
graph = ContextGraph()
@@ -0,0 +1,564 @@
"""
Regression tests for Context Explainability Output Fixes.
Covers:
- Readable decision text preservation in ContextGraph nodes and reconstruction paths
- Enriched causal/path outputs (from_scenario, to_scenario, scenario/outcome/category dicts)
- PolicyEngine.get_affected_decisions() consistent metadata across Cypher and fallback branches
- EntityLinker similarity flows return full enriched payloads
- KG consumer compatibility (node_embeddings, link_predictor, centrality_calculator, path_finder)
when ContextGraph is used as the graph store and get_neighbors returns enriched dicts
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch, PropertyMock
from typing import Any, Dict, List
from semantica.context.context_graph import ContextGraph
from semantica.context.decision_models import Decision
from semantica.context.entity_linker import EntityLinker
from semantica.context.policy_engine import PolicyEngine
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_decision(decision_id: str, scenario: str, reasoning: str,
category: str = "test", outcome: str = "approved",
confidence: float = 0.9, decision_maker: str = "agent_1") -> Decision:
return Decision(
decision_id=decision_id,
category=category,
scenario=scenario,
reasoning=reasoning,
outcome=outcome,
confidence=confidence,
timestamp=datetime.now(),
decision_maker=decision_maker,
)
# ===========================================================================
# Group 1 Readable Decision Text Preservation
# ===========================================================================
class TestReadableDecisionTextPreservation:
"""Decision-node storage preserves full human-readable text, not IDs."""
def test_add_decision_scenario_stored_as_content(self):
"""scenario is stored as node.content, not as an opaque ID."""
g = ContextGraph()
d = _make_decision(
"d1",
scenario="Loan application for first-time buyer: $300k, FICO 720",
reasoning="Strong credit profile with stable income"
)
g.add_decision(d)
node = g.nodes["d1"]
assert node.content == d.scenario, (
"node.content must equal the full human-readable scenario string"
)
assert node.content != "d1", "node.content must NOT be the node ID"
def test_add_decision_reasoning_preserved_in_properties(self):
"""Full reasoning text is stored in node.properties, not truncated."""
g = ContextGraph()
long_reasoning = (
"Customer has 8-year payment history, zero delinquencies, debt-to-income "
"ratio of 28%, salary verified at $95k/year via W-2. Risk score: LOW."
)
d = _make_decision("d2", "Credit card limit review", long_reasoning)
g.add_decision(d)
node = g.nodes["d2"]
assert node.properties["reasoning"] == long_reasoning
assert len(node.properties["reasoning"]) > 50
def test_find_precedents_returns_decision_with_readable_scenario(self):
"""find_precedents() returns Decision objects whose .scenario is readable text."""
g = ContextGraph()
cause = _make_decision(
"cause_1",
scenario="Overdraft protection request account in good standing 5 yrs",
reasoning="Long account history, low overdraft frequency"
)
effect = _make_decision(
"effect_1",
scenario="Fee waiver granted due to precedent overdraft approval",
reasoning="Follows precedent cause_1"
)
g.add_decision(cause)
g.add_decision(effect)
g.add_causal_relationship("cause_1", "effect_1", "PRECEDENT_FOR")
precedents = g.find_precedents("effect_1")
assert len(precedents) >= 1, "Should return at least one precedent"
p = precedents[0]
assert isinstance(p, Decision)
assert p.scenario, "Returned Decision.scenario must not be empty"
assert "overdraft" in p.scenario.lower() or "Overdraft" in p.scenario, (
f"scenario should contain human-readable text, got: {p.scenario!r}"
)
assert p.scenario != "cause_1", "scenario must NOT be the raw node ID"
def test_get_causal_chain_returns_readable_text(self):
"""get_causal_chain() returns Decision objects with scenario text from node.content."""
g = ContextGraph()
for did, scenario in [
("root", "Initial fraud alert triggered on account #7734"),
("mid", "Temporary hold placed pending fraud investigation"),
("leaf", "Card blocked; customer notified via SMS"),
]:
g.add_decision(_make_decision(did, scenario, f"reasoning for {did}"))
g.add_causal_relationship("root", "mid", "CAUSED")
g.add_causal_relationship("mid", "leaf", "CAUSED")
chain = g.get_causal_chain("leaf", direction="upstream")
assert len(chain) >= 1
for dec in chain:
assert isinstance(dec, Decision)
assert dec.scenario, "Each chained Decision must have non-empty scenario"
assert dec.scenario != dec.decision_id, (
f"scenario '{dec.scenario}' must not equal the decision_id"
)
# ===========================================================================
# Group 2 Enriched Causal / Path Outputs
# ===========================================================================
class TestEnrichedCausalOutputs:
"""trace_decision_causality and analyze_decision_influence return readable dicts."""
def _graph_with_decisions(self):
g = ContextGraph()
alpha_id = g.record_decision(
category="mortgage",
scenario="Approve mortgage for tech employee earning $180k",
reasoning="Strong credit profile and stable income verified",
outcome="approved",
confidence=0.92,
entities=["tech_employee", "mortgage_dept"],
)
beta_id = g.record_decision(
category="auto_loan",
scenario="Approve auto-loan backed by employer letter",
reasoning="Employer verification provided, income above threshold",
outcome="approved",
confidence=0.85,
entities=["tech_employee", "auto_dept"],
)
return g, alpha_id, beta_id
def test_trace_decision_causality_hops_have_scenario_fields(self):
"""Each causal hop includes from_scenario and to_scenario with readable text."""
g, alpha_id, beta_id = self._graph_with_decisions()
chains = g.trace_decision_causality(beta_id, max_depth=3)
# At least one hop should exist (shared entity creates causal link)
if chains:
for hop_list in chains:
for hop in hop_list:
assert "from" in hop, "hop must have 'from' key"
assert "to" in hop, "hop must have 'to' key"
assert "from_scenario" in hop, (
f"hop must have 'from_scenario' key, got keys: {list(hop.keys())}"
)
assert "to_scenario" in hop, (
f"hop must have 'to_scenario' key, got keys: {list(hop.keys())}"
)
# Scenarios must be strings, not empty IDs
assert isinstance(hop["from_scenario"], str)
assert isinstance(hop["to_scenario"], str)
def test_analyze_decision_influence_direct_influence_is_enriched_dicts(self):
"""direct_influence list contains dicts with decision_id, scenario, outcome, category."""
g, alpha_id, beta_id = self._graph_with_decisions()
result = g.analyze_decision_influence(alpha_id)
assert "direct_influence" in result
assert isinstance(result["direct_influence"], list)
for item in result["direct_influence"]:
assert isinstance(item, dict), (
f"direct_influence items must be dicts, got {type(item)}"
)
for field in ("decision_id", "scenario", "outcome", "category"):
assert field in item, (
f"influence item missing field '{field}', keys: {list(item.keys())}"
)
def test_analyze_decision_influence_scores_contain_readable_fields(self):
"""influence_scores entries include scenario/outcome/category alongside score."""
g, alpha_id, beta_id = self._graph_with_decisions()
result = g.analyze_decision_influence(alpha_id)
assert "influence_scores" in result
for item in result["influence_scores"]:
assert "score" in item
assert "decision_id" in item
assert "scenario" in item
assert "category" in item
assert "outcome" in item
# ===========================================================================
# Group 3 PolicyEngine Consistent Decision Metadata
# ===========================================================================
class TestPolicyEngineAffectedDecisions:
"""get_affected_decisions() returns enriched metadata from both branches."""
def _mock_store_with_query(self, records):
store = MagicMock()
store.execute_query.return_value = records
return store
def test_cypher_branch_returns_scenario_category_outcome_confidence(self):
"""Cypher results include scenario/category/outcome/confidence with actual values."""
records = [
{
"decision_id": "dec_abc",
"scenario": "Increase credit limit for platinum member",
"category": "credit",
"outcome": "approved",
"confidence": 0.88,
}
]
store = self._mock_store_with_query(records)
pe = PolicyEngine(graph_store=store)
affected = pe.get_affected_decisions("policy_1", "v1", "v2")
assert len(affected) == 1
d = affected[0]
assert d["scenario"] == "Increase credit limit for platinum member", (
f"scenario must be readable text, got: {d['scenario']!r}"
)
assert d["category"] == "credit"
assert d["outcome"] == "approved"
assert d["confidence"] == pytest.approx(0.88, abs=1e-6)
def test_fallback_branch_enriches_from_context_graph_nodes(self):
"""Fallback branch reads scenario/category/outcome/confidence from ContextGraph nodes."""
g = ContextGraph()
d = _make_decision(
"dec_xyz",
scenario="Block account after 3 failed PIN attempts",
reasoning="Security policy v1 requires lockout",
category="security",
outcome="blocked",
confidence=0.99,
)
g.add_decision(d)
# Add a policy node and the APPLIED_POLICY edge
g.add_node("policy_2:v1", "Policy", {"policy_id": "policy_2", "version": "v1"})
g.add_edge("dec_xyz", "policy_2:v1", "APPLIED_POLICY")
pe = PolicyEngine(graph_store=g)
affected = pe.get_affected_decisions("policy_2", "v1", "v2")
assert len(affected) == 1
d_out = affected[0]
assert d_out["decision_id"] == "dec_xyz"
# scenario must come from node.content, not be empty or the raw ID
assert d_out["scenario"], "scenario must not be empty"
assert d_out["scenario"] != "dec_xyz", (
f"scenario should be readable text not the node ID, got: {d_out['scenario']!r}"
)
assert "PIN" in d_out["scenario"] or "Block" in d_out["scenario"], (
f"scenario should reflect stored decision text, got: {d_out['scenario']!r}"
)
def test_both_branches_return_same_key_shape(self):
"""Both Cypher and fallback branches return dicts with identical required keys."""
required_keys = {"decision_id", "scenario", "category", "outcome", "confidence"}
# Cypher branch
store_cypher = self._mock_store_with_query([{
"decision_id": "d1",
"scenario": "some scenario",
"category": "cat",
"outcome": "out",
"confidence": 0.5,
}])
pe_c = PolicyEngine(graph_store=store_cypher)
cypher_result = pe_c.get_affected_decisions("p", "v1", "v2")
assert len(cypher_result) == 1
assert required_keys.issubset(cypher_result[0].keys()), (
f"Cypher branch missing keys: {required_keys - cypher_result[0].keys()}"
)
# Fallback branch
g = ContextGraph()
g.add_decision(_make_decision("d2", "fallback scenario", "fallback reason"))
g.add_node("p2:v1", "Policy", {})
g.add_edge("d2", "p2:v1", "APPLIED_POLICY")
pe_f = PolicyEngine(graph_store=g)
fallback_result = pe_f.get_affected_decisions("p2", "v1", "v2")
assert len(fallback_result) == 1
assert required_keys.issubset(fallback_result[0].keys()), (
f"Fallback branch missing keys: {required_keys - fallback_result[0].keys()}"
)
# ===========================================================================
# Group 4 EntityLinker Similarity Payloads
# ===========================================================================
class TestEntityLinkerSimilarityPayloads:
"""EntityLinker similarity flows return enriched dicts, not bare IDs."""
def _linker(self):
return EntityLinker(
knowledge_graph={
"entities": [
{
"id": "ent_python",
"text": "Python programming language",
"type": "Technology",
},
{
"id": "ent_java",
"text": "Java programming language",
"type": "Technology",
},
{
"id": "ent_sql",
"text": "SQL database query language",
"type": "Language",
},
]
}
)
def test_find_similar_entities_returns_full_payload_keys(self):
"""find_similar_entities() returns dicts with entity_id, text, type, uri, similarity."""
linker = self._linker()
results = linker.find_similar_entities("Python language", threshold=0.1)
assert isinstance(results, list)
assert len(results) >= 1, "Should find at least one similar entity"
for item in results:
assert isinstance(item, dict)
for field in ("entity_id", "text", "type", "similarity"):
assert field in item, (
f"find_similar_entities result missing field '{field}', got: {list(item.keys())}"
)
# entity_id must be the stored ID, not empty
assert item["entity_id"], "entity_id must not be empty"
# similarity must be a non-negative float
assert isinstance(item["similarity"], (int, float))
assert item["similarity"] >= 0.0
def test_find_similar_entities_text_field_is_human_readable(self):
"""text field in similarity results is human-readable entity text, not an ID."""
linker = self._linker()
results = linker.find_similar_entities("Python language", threshold=0.1)
assert len(results) >= 1
for item in results:
assert item["text"] != item["entity_id"], (
f"text should be human-readable, not the entity ID: {item['text']!r}"
)
assert len(item["text"]) > 2
def test_find_similar_entities_sorted_by_similarity_descending(self):
"""Results are sorted by similarity in descending order."""
linker = self._linker()
results = linker.find_similar_entities("Python language", threshold=0.0)
if len(results) >= 2:
for i in range(len(results) - 1):
assert results[i]["similarity"] >= results[i + 1]["similarity"], (
"Results must be sorted by similarity descending"
)
def test_find_similar_public_alias_returns_full_payload(self):
"""find_similar() public alias delegates to find_similar_entities and returns full dicts."""
linker = self._linker()
results = linker.find_similar("Python language", threshold=0.1)
assert isinstance(results, list)
for item in results:
assert isinstance(item, dict)
assert "entity_id" in item
assert "text" in item
assert "similarity" in item
def test_find_similar_with_entity_dict_input(self):
"""find_similar() accepts an EntityDict as input and returns full dicts."""
linker = self._linker()
entity_dict = {"text": "Java language", "type": "Technology"}
results = linker.find_similar(entity_dict, threshold=0.1)
assert isinstance(results, list)
for item in results:
assert "entity_id" in item
assert "similarity" in item
def test_find_linked_entities_creates_entity_links_with_ids(self):
"""_find_linked_entities creates EntityLink objects with valid target entity IDs."""
linker = self._linker()
linker.assign_uri("ent_python", "Python programming language", "Technology")
links = linker._find_linked_entities(
entity_id="my_entity",
entity_text="Python language",
entity_type="Technology",
all_entities=[],
context=None,
)
assert isinstance(links, list)
for link in links:
# target_entity_id must be a stored entity ID, not empty or equal to text
assert link.target_entity_id, "target_entity_id must not be empty"
assert link.target_entity_id.startswith("ent_"), (
f"target_entity_id should be a stored entity ID, got: {link.target_entity_id!r}"
)
assert link.confidence >= 0.0
# ===========================================================================
# Group 5 KG Consumer Compatibility
# ===========================================================================
class TestKGConsumerCompatibility:
"""KG algorithms normalize enriched neighbor/node dicts from ContextGraph correctly."""
def _graph_with_nodes(self, pairs):
"""Build a ContextGraph with given (id, label) pairs connected in a chain."""
g = ContextGraph()
for nid, label in pairs:
g.add_node(nid, label, {"name": nid})
# Connect in order
ids = [nid for nid, _ in pairs]
for i in range(len(ids) - 1):
g.add_edge(ids[i], ids[i + 1], "RELATED_TO")
return g
def test_node_embedder_build_adjacency_normalizes_enriched_dicts(self):
"""NodeEmbedder._build_adjacency strips enriched dicts to node IDs (no crash, no None)."""
from semantica.kg.node_embeddings import NodeEmbedder
g = self._graph_with_nodes([("A", "Person"), ("B", "Person"), ("C", "Person")])
embedder = NodeEmbedder()
# Verify get_neighbors on ContextGraph returns dicts (enriched)
raw = g.get_neighbors("A")
assert isinstance(raw[0], dict), "ContextGraph.get_neighbors should return dicts"
assert "id" in raw[0]
adjacency = embedder._build_adjacency(g, ["Person", "Person"], ["RELATED_TO"])
# Each node maps to a list of plain string IDs
for node_id, neighbors in adjacency.items():
assert isinstance(node_id, str)
for nb in neighbors:
assert isinstance(nb, str), (
f"adjacency neighbor must be a string ID, got {type(nb)}: {nb!r}"
)
assert nb is not None
def test_link_predictor_get_node_neighbors_normalizes_enriched_dicts(self):
"""LinkPredictor._get_node_neighbors strips enriched dicts to plain IDs."""
from semantica.kg.link_predictor import LinkPredictor
g = self._graph_with_nodes([("X", "Item"), ("Y", "Item"), ("Z", "Item")])
predictor = LinkPredictor()
neighbors = predictor._get_node_neighbors(g, "X")
assert isinstance(neighbors, list)
for nb in neighbors:
assert isinstance(nb, str), (
f"neighbor must be a plain string ID, got {type(nb)}: {nb!r}"
)
assert nb is not None
def test_link_predictor_score_link_works_with_context_graph(self):
"""score_link() runs without error when given a ContextGraph store."""
from semantica.kg.link_predictor import LinkPredictor
g = self._graph_with_nodes([
("n1", "Entity"), ("n2", "Entity"), ("n3", "Entity")
])
predictor = LinkPredictor()
score = predictor.score_link(g, "n1", "n3", method="common_neighbors")
assert isinstance(score, (int, float))
assert score >= 0.0
def test_centrality_calculator_get_filtered_neighbors_normalizes_dicts(self):
"""CentralityCalculator._get_filtered_neighbors strips enriched dicts to IDs."""
from semantica.kg.centrality_calculator import CentralityCalculator
g = self._graph_with_nodes([("c1", "Node"), ("c2", "Node"), ("c3", "Node")])
calc = CentralityCalculator()
neighbors = calc._get_filtered_neighbors(g, "c1", relationship_types=None)
assert isinstance(neighbors, list)
for nb in neighbors:
assert isinstance(nb, str), (
f"filtered neighbor must be a plain string ID, got {type(nb)}: {nb!r}"
)
def test_centrality_calculator_degree_centrality_works_with_context_graph(self):
"""calculate_degree_centrality() works with ContextGraph as the graph store."""
from semantica.kg.centrality_calculator import CentralityCalculator
g = self._graph_with_nodes([
("hub", "Node"), ("spoke1", "Node"), ("spoke2", "Node")
])
g.add_edge("hub", "spoke2", "RELATED_TO") # hub has extra edge
calc = CentralityCalculator()
result = calc.calculate_degree_centrality(g)
assert isinstance(result, dict)
# result has keys: centrality, rankings, max_degree, total_nodes
assert "centrality" in result
centrality = result["centrality"]
assert isinstance(centrality, dict)
assert len(centrality) > 0
for node_id, score in centrality.items():
assert isinstance(node_id, str)
assert isinstance(score, (int, float))
assert score >= 0.0
def test_path_finder_get_neighbors_normalizes_enriched_dicts(self):
"""PathFinder._get_neighbors strips enriched dicts to (id, edge_data) tuples."""
from semantica.kg.path_finder import PathFinder
g = self._graph_with_nodes([("p1", "Stop"), ("p2", "Stop"), ("p3", "Stop")])
finder = PathFinder()
neighbors = finder._get_neighbors(g, "p1")
assert isinstance(neighbors, list)
for item in neighbors:
node_id, edge_data = item
assert isinstance(node_id, str), (
f"neighbor node_id must be a plain string, got {type(node_id)}: {node_id!r}"
)
assert node_id is not None
def test_path_finder_dijkstra_works_with_context_graph(self):
"""dijkstra_shortest_path() runs without error on ContextGraph."""
from semantica.kg.path_finder import PathFinder
g = self._graph_with_nodes([
("start", "Node"), ("mid", "Node"), ("end", "Node")
])
finder = PathFinder()
result = finder.dijkstra_shortest_path(g, "start", "end")
assert result is not None
assert isinstance(result, list)
assert "start" in result
assert "end" in result
@@ -49,6 +49,38 @@ class TestContextGraphDecisions:
assert node.properties["confidence"] == sample_decision.confidence
assert node.properties["decision_maker"] == sample_decision.decision_maker
def test_add_decision_kwargs_form(self, context_graph):
"""add_decision() accepts kwargs directly (no Decision object required)."""
decision_id = context_graph.add_decision(
category="loan_approval",
scenario="Mortgage application — 780 credit score",
reasoning="Strong credit history, low DTI",
outcome="approved",
confidence=0.95,
)
assert isinstance(decision_id, str)
assert len(decision_id) > 0
node = context_graph.nodes[decision_id]
assert node.node_type in ("Decision", "decision")
assert node.properties["category"] == "loan_approval"
assert node.properties["outcome"] == "approved"
assert node.properties["confidence"] == 0.95
def test_add_decision_kwargs_and_object_both_return_id(self, context_graph, sample_decision):
"""Both call forms return a non-empty decision ID string."""
id_from_object = context_graph.add_decision(sample_decision)
id_from_kwargs = context_graph.add_decision(
category="test",
scenario="test scenario",
reasoning="test reasoning",
outcome="approved",
confidence=0.8,
)
assert isinstance(id_from_object, str) and len(id_from_object) > 0
assert isinstance(id_from_kwargs, str) and len(id_from_kwargs) > 0
def test_add_decision_with_embeddings(self, context_graph):
"""Test adding decision with embeddings."""
decision = Decision(
+424
View File
@@ -0,0 +1,424 @@
"""
Tests for semantica/explorer/utils/rdf_parser.py
Covers:
- parse_skos_file() with Turtle and RDF/XML formats
- ConceptScheme and Concept node extraction
- Label priority resolution (en > en-* > untagged > fallback)
- altLabel collection
- Structural edge extraction (broader/narrower/inScheme/related/topConceptOf/hasTopConcept)
- Edge filtering: edges with unknown endpoints are dropped
- Invalid bytes raises ValueError
- Empty graph returns empty lists
- _get_best_label and _get_all_labels helpers
"""
import pytest
import rdflib
from rdflib.namespace import RDF, SKOS
from semantica.explorer.utils.rdf_parser import (
_get_all_labels,
_get_best_label,
parse_skos_file,
)
# ---------------------------------------------------------------------------
# Sample TTL fixtures
# ---------------------------------------------------------------------------
MINIMAL_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:Animals a skos:ConceptScheme ;
skos:prefLabel "Animals"@en .
ex:Mammal a skos:Concept ;
skos:prefLabel "Mammal"@en ;
skos:inScheme ex:Animals .
ex:Dog a skos:Concept ;
skos:prefLabel "Dog"@en ;
skos:broader ex:Mammal ;
skos:inScheme ex:Animals .
"""
MULTILINGUAL_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:C1 a skos:Concept ;
skos:prefLabel "French Only"@fr ;
skos:prefLabel "English Label"@en ;
skos:prefLabel "British English"@en-GB ;
skos:altLabel "Alias One"@en ;
skos:altLabel "Alias Two"@en .
"""
UNTAGGED_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:C2 a skos:Concept ;
skos:prefLabel "No Language Tag" ;
skos:altLabel "alt1" ;
skos:altLabel "alt2" .
"""
FALLBACK_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:C3 a skos:Concept ;
skos:prefLabel "Nur Deutsch"@de .
"""
ALL_EDGE_TYPES_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:S1 a skos:ConceptScheme ;
skos:prefLabel "Scheme One" .
ex:A a skos:Concept ;
skos:prefLabel "A" ;
skos:inScheme ex:S1 ;
skos:topConceptOf ex:S1 .
ex:B a skos:Concept ;
skos:prefLabel "B" ;
skos:broader ex:A ;
skos:inScheme ex:S1 .
ex:C a skos:Concept ;
skos:prefLabel "C" ;
skos:related ex:B ;
skos:inScheme ex:S1 .
ex:S1 skos:hasTopConcept ex:A .
"""
# An edge pointing to an external URI not declared as a Concept/ConceptScheme
ORPHAN_EDGE_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:Known a skos:Concept ;
skos:prefLabel "Known" ;
skos:broader ex:ExternalConcept .
"""
MINIMAL_RDF_XML = b"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:skos="http://www.w3.org/2004/02/skos/core#"
xmlns:ex="http://example.org/">
<skos:ConceptScheme rdf:about="http://example.org/SchemeX">
<skos:prefLabel xml:lang="en">Scheme X</skos:prefLabel>
</skos:ConceptScheme>
<skos:Concept rdf:about="http://example.org/ConceptY">
<skos:prefLabel xml:lang="en">Concept Y</skos:prefLabel>
<skos:inScheme rdf:resource="http://example.org/SchemeX"/>
</skos:Concept>
</rdf:RDF>
"""
# ---------------------------------------------------------------------------
# Helper: get node by URI
# ---------------------------------------------------------------------------
def _node(nodes, uri):
return next((n for n in nodes if n["id"] == uri), None)
def _edges_of_type(edges, edge_type):
return [e for e in edges if e["type"] == edge_type]
# ---------------------------------------------------------------------------
# parse_skos_file — basic extraction
# ---------------------------------------------------------------------------
class TestParseSkosFileBasic:
def test_returns_tuple_of_two_lists(self):
nodes, edges = parse_skos_file(MINIMAL_TTL)
assert isinstance(nodes, list)
assert isinstance(edges, list)
def test_extracts_concept_scheme(self):
nodes, _ = parse_skos_file(MINIMAL_TTL)
scheme = _node(nodes, "http://example.org/Animals")
assert scheme is not None
assert scheme["type"] == "skos:ConceptScheme"
assert scheme["properties"]["content"] == "Animals"
def test_extracts_concepts(self):
nodes, _ = parse_skos_file(MINIMAL_TTL)
uris = {n["id"] for n in nodes}
assert "http://example.org/Mammal" in uris
assert "http://example.org/Dog" in uris
def test_concept_type_tag(self):
nodes, _ = parse_skos_file(MINIMAL_TTL)
mammal = _node(nodes, "http://example.org/Mammal")
assert mammal["type"] == "skos:Concept"
def test_node_has_required_keys(self):
nodes, _ = parse_skos_file(MINIMAL_TTL)
for n in nodes:
assert "id" in n
assert "type" in n
assert "properties" in n
assert "content" in n["properties"]
assert "alt_labels" in n["properties"]
assert "description" in n["properties"]
def test_edge_has_required_keys(self):
_, edges = parse_skos_file(MINIMAL_TTL)
for e in edges:
assert "source_id" in e
assert "target_id" in e
assert "type" in e
assert "weight" in e
assert "properties" in e
def test_edge_weight_default(self):
_, edges = parse_skos_file(MINIMAL_TTL)
assert all(e["weight"] == 1.0 for e in edges)
# ---------------------------------------------------------------------------
# parse_skos_file — label priority
# ---------------------------------------------------------------------------
class TestLabelPriority:
def test_en_preferred_over_fr(self):
nodes, _ = parse_skos_file(MULTILINGUAL_TTL)
c1 = _node(nodes, "http://example.org/C1")
assert c1 is not None
assert c1["properties"]["content"] == "English Label"
def test_untagged_used_when_no_en(self):
nodes, _ = parse_skos_file(UNTAGGED_TTL)
c2 = _node(nodes, "http://example.org/C2")
assert c2 is not None
assert c2["properties"]["content"] == "No Language Tag"
def test_fallback_to_any_language(self):
nodes, _ = parse_skos_file(FALLBACK_TTL)
c3 = _node(nodes, "http://example.org/C3")
assert c3 is not None
assert c3["properties"]["content"] == "Nur Deutsch"
def test_uri_fragment_used_when_no_pref_label(self):
ttl = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:NoLabel a skos:Concept .
"""
nodes, _ = parse_skos_file(ttl)
n = _node(nodes, "http://example.org/NoLabel")
assert n is not None
assert n["properties"]["content"] == "NoLabel"
# ---------------------------------------------------------------------------
# parse_skos_file — altLabels
# ---------------------------------------------------------------------------
class TestAltLabels:
def test_alt_labels_collected(self):
nodes, _ = parse_skos_file(MULTILINGUAL_TTL)
c1 = _node(nodes, "http://example.org/C1")
assert set(c1["properties"]["alt_labels"]) == {"Alias One", "Alias Two"}
def test_alt_labels_empty_when_none(self):
nodes, _ = parse_skos_file(MINIMAL_TTL)
mammal = _node(nodes, "http://example.org/Mammal")
assert mammal["properties"]["alt_labels"] == []
def test_alt_labels_deduped(self):
ttl = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:C a skos:Concept ;
skos:prefLabel "C" ;
skos:altLabel "same"@en ;
skos:altLabel "same"@en .
"""
nodes, _ = parse_skos_file(ttl)
c = _node(nodes, "http://example.org/C")
assert c["properties"]["alt_labels"].count("same") == 1
# ---------------------------------------------------------------------------
# parse_skos_file — edge types
# ---------------------------------------------------------------------------
class TestEdgeTypes:
def setup_method(self):
self.nodes, self.edges = parse_skos_file(ALL_EDGE_TYPES_TTL)
def test_in_scheme_edges(self):
in_scheme = _edges_of_type(self.edges, "skos:inScheme")
assert len(in_scheme) >= 2 # A, B, C all inScheme S1
def test_broader_edge(self):
broader = _edges_of_type(self.edges, "skos:broader")
assert any(
e["source_id"] == "http://example.org/B" and
e["target_id"] == "http://example.org/A"
for e in broader
)
def test_related_edge(self):
related = _edges_of_type(self.edges, "skos:related")
assert any(
e["source_id"] == "http://example.org/C" and
e["target_id"] == "http://example.org/B"
for e in related
)
def test_top_concept_of_edge(self):
top_concept_of = _edges_of_type(self.edges, "skos:topConceptOf")
assert any(
e["source_id"] == "http://example.org/A" and
e["target_id"] == "http://example.org/S1"
for e in top_concept_of
)
def test_has_top_concept_edge(self):
has_top = _edges_of_type(self.edges, "skos:hasTopConcept")
assert any(
e["source_id"] == "http://example.org/S1" and
e["target_id"] == "http://example.org/A"
for e in has_top
)
# ---------------------------------------------------------------------------
# parse_skos_file — edge filtering (orphan edges dropped)
# ---------------------------------------------------------------------------
class TestOrphanEdgeFiltering:
def test_edge_to_external_uri_is_dropped(self):
nodes, edges = parse_skos_file(ORPHAN_EDGE_TTL)
# ex:ExternalConcept is not declared as a Concept/ConceptScheme
# so the broader edge should be dropped
assert len(edges) == 0
def test_known_node_is_still_extracted(self):
nodes, _ = parse_skos_file(ORPHAN_EDGE_TTL)
assert _node(nodes, "http://example.org/Known") is not None
# ---------------------------------------------------------------------------
# parse_skos_file — empty and error cases
# ---------------------------------------------------------------------------
class TestEmptyAndErrors:
def test_empty_graph_returns_empty_lists(self):
empty_ttl = b"@prefix skos: <http://www.w3.org/2004/02/skos/core#> .\n"
nodes, edges = parse_skos_file(empty_ttl)
assert nodes == []
assert edges == []
def test_invalid_bytes_raises_value_error(self):
with pytest.raises(ValueError, match="Failed to parse RDF file"):
parse_skos_file(b"this is not valid turtle !!!!", rdf_format="turtle")
def test_invalid_xml_raises_value_error(self):
with pytest.raises(ValueError, match="Failed to parse RDF file"):
parse_skos_file(b"<not-valid-xml>", rdf_format="xml")
# ---------------------------------------------------------------------------
# parse_skos_file — RDF/XML format
# ---------------------------------------------------------------------------
class TestRdfXmlFormat:
def test_parses_rdf_xml(self):
nodes, edges = parse_skos_file(MINIMAL_RDF_XML, rdf_format="xml")
uris = {n["id"] for n in nodes}
assert "http://example.org/SchemeX" in uris
assert "http://example.org/ConceptY" in uris
def test_rdf_xml_scheme_type(self):
nodes, _ = parse_skos_file(MINIMAL_RDF_XML, rdf_format="xml")
scheme = _node(nodes, "http://example.org/SchemeX")
assert scheme["type"] == "skos:ConceptScheme"
assert scheme["properties"]["content"] == "Scheme X"
def test_rdf_xml_in_scheme_edge(self):
_, edges = parse_skos_file(MINIMAL_RDF_XML, rdf_format="xml")
in_scheme = _edges_of_type(edges, "skos:inScheme")
assert any(
e["source_id"] == "http://example.org/ConceptY" and
e["target_id"] == "http://example.org/SchemeX"
for e in in_scheme
)
# ---------------------------------------------------------------------------
# _get_best_label helper
# ---------------------------------------------------------------------------
class TestGetBestLabel:
def _make_graph(self, triples_ttl: bytes) -> rdflib.Graph:
g = rdflib.Graph()
g.parse(data=triples_ttl, format="turtle")
return g
def test_returns_en_when_available(self):
ttl = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:X skos:prefLabel "English"@en ;
skos:prefLabel "Deutsch"@de .
"""
g = self._make_graph(ttl)
result = _get_best_label(g, rdflib.URIRef("http://example.org/X"), SKOS.prefLabel)
assert result == "English"
def test_returns_empty_string_when_no_labels(self):
g = rdflib.Graph()
result = _get_best_label(g, rdflib.URIRef("http://example.org/X"), SKOS.prefLabel)
assert result == ""
def test_en_variant_beats_untagged(self):
ttl = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:X skos:prefLabel "No Tag" ;
skos:prefLabel "British"@en-GB .
"""
g = self._make_graph(ttl)
result = _get_best_label(g, rdflib.URIRef("http://example.org/X"), SKOS.prefLabel)
assert result == "British"
# ---------------------------------------------------------------------------
# _get_all_labels helper
# ---------------------------------------------------------------------------
class TestGetAllLabels:
def test_returns_all_values(self):
ttl = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:X skos:altLabel "A"@en ;
skos:altLabel "B"@fr ;
skos:altLabel "C" .
"""
g = rdflib.Graph()
g.parse(data=ttl, format="turtle")
result = _get_all_labels(g, rdflib.URIRef("http://example.org/X"), SKOS.altLabel)
assert set(result) == {"A", "B", "C"}
def test_returns_empty_list_when_no_labels(self):
g = rdflib.Graph()
result = _get_all_labels(g, rdflib.URIRef("http://example.org/X"), SKOS.altLabel)
assert result == []
+348
View File
@@ -0,0 +1,348 @@
"""
Tests for semantica/explorer/routes/vocabulary.py
Covers:
- GET /api/vocabulary/schemes
- GET /api/vocabulary/hierarchy
- POST /api/vocabulary/import
"""
import pytest
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from semantica.explorer.routes.vocabulary import router
from semantica.explorer.dependencies import get_session
# ---------------------------------------------------------------------------
# App + dependency override setup
# ---------------------------------------------------------------------------
app = FastAPI()
app.include_router(router)
mock_session = MagicMock()
app.dependency_overrides[get_session] = lambda: mock_session
client = TestClient(app)
def setup_function():
"""Reset mock call history before each test to prevent state pollution."""
mock_session.reset_mock()
# ---------------------------------------------------------------------------
# GET /api/vocabulary/schemes
# ---------------------------------------------------------------------------
def test_list_schemes_returns_correct_shape():
"""Maps skos:ConceptScheme nodes to VocabularyScheme schema."""
mock_session.get_nodes.return_value = ([
{
"id": "http://example.org/Scheme1",
"type": "skos:ConceptScheme",
"properties": {
"content": "My Test Scheme",
"description": "A scheme for testing"
}
}
], 1)
response = client.get("/api/vocabulary/schemes")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["uri"] == "http://example.org/Scheme1"
assert data[0]["label"] == "My Test Scheme"
assert data[0]["description"] == "A scheme for testing"
def test_list_schemes_empty_graph():
"""Returns empty list when no ConceptScheme nodes exist."""
mock_session.get_nodes.return_value = ([], 0)
response = client.get("/api/vocabulary/schemes")
assert response.status_code == 200
assert response.json() == []
def test_list_schemes_no_description():
"""Description field is optional — None when not present in properties."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/S", "type": "skos:ConceptScheme",
"properties": {"content": "Minimal"}}
], 1)
response = client.get("/api/vocabulary/schemes")
assert response.status_code == 200
assert response.json()[0]["description"] is None
def test_list_schemes_metadata_envelope():
"""Label is read from 'metadata' envelope when 'properties' key absent."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/S", "type": "skos:ConceptScheme",
"metadata": {"content": "Via Metadata"}}
], 1)
response = client.get("/api/vocabulary/schemes")
assert response.status_code == 200
assert response.json()[0]["label"] == "Via Metadata"
# ---------------------------------------------------------------------------
# GET /api/vocabulary/hierarchy
# ---------------------------------------------------------------------------
def test_hierarchy_parent_child_via_broader():
"""broader edge: child → parent. Returns single root with one child."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/Parent", "type": "skos:Concept",
"properties": {"content": "Parent Node"}},
{"id": "http://example.org/Child", "type": "skos:Concept",
"properties": {"content": "Child Node"}}
], 2)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/Parent", "target": "http://example.org/Scheme1",
"type": "skos:inScheme"},
{"source": "http://example.org/Child", "target": "http://example.org/Scheme1",
"type": "skos:inScheme"},
{"source": "http://example.org/Child", "target": "http://example.org/Parent",
"type": "skos:broader"},
], 3)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/Scheme1")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
root = data[0]
assert root["uri"] == "http://example.org/Parent"
assert root["pref_label"] == "Parent Node"
assert len(root["children"]) == 1
child = root["children"][0]
assert child["uri"] == "http://example.org/Child"
assert child["pref_label"] == "Child Node"
assert child["children"] is None
def test_hierarchy_parent_child_via_narrower():
"""narrower edge: parent → child. Same tree as broader, different edge direction."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/P", "type": "skos:Concept",
"properties": {"content": "P"}},
{"id": "http://example.org/C", "type": "skos:Concept",
"properties": {"content": "C"}}
], 2)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/P", "target": "http://example.org/S",
"type": "skos:inScheme"},
{"source": "http://example.org/C", "target": "http://example.org/S",
"type": "skos:inScheme"},
# narrower: P → C means C is a child of P
{"source": "http://example.org/P", "target": "http://example.org/C",
"type": "skos:narrower"},
], 3)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/S")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["uri"] == "http://example.org/P"
assert len(data[0]["children"]) == 1
assert data[0]["children"][0]["uri"] == "http://example.org/C"
def test_hierarchy_membership_via_top_concept_of():
"""topConceptOf edge includes node in scheme without inScheme edge."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/Top", "type": "skos:Concept",
"properties": {"content": "Top"}}
], 1)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/Top", "target": "http://example.org/S",
"type": "skos:topConceptOf"},
], 1)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/S")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["uri"] == "http://example.org/Top"
def test_hierarchy_membership_via_has_top_concept():
"""hasTopConcept edge (scheme → concept) includes the target concept."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/TC", "type": "skos:Concept",
"properties": {"content": "TopConcept"}}
], 1)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/S", "target": "http://example.org/TC",
"type": "skos:hasTopConcept"},
], 1)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/S")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["uri"] == "http://example.org/TC"
def test_hierarchy_empty_scheme():
"""No concepts in scheme returns empty list."""
mock_session.get_nodes.return_value = ([], 0)
mock_session.get_edges.return_value = ([], 0)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/Empty")
assert response.status_code == 200
assert response.json() == []
def test_hierarchy_flat_scheme_all_roots():
"""All concepts without parent relationships are returned as roots."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/A", "type": "skos:Concept",
"properties": {"content": "A"}},
{"id": "http://example.org/B", "type": "skos:Concept",
"properties": {"content": "B"}},
], 2)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/A", "target": "http://example.org/S",
"type": "skos:inScheme"},
{"source": "http://example.org/B", "target": "http://example.org/S",
"type": "skos:inScheme"},
], 2)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/S")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
uris = {n["uri"] for n in data}
assert uris == {"http://example.org/A", "http://example.org/B"}
def test_hierarchy_missing_scheme_param():
"""scheme query param is required — returns 422 when omitted."""
response = client.get("/api/vocabulary/hierarchy")
assert response.status_code == 422
def test_hierarchy_cycle_does_not_hang():
"""Cyclic broader edges must not cause infinite recursion during serialization."""
mock_session.get_nodes.return_value = ([
{"id": "http://example.org/A", "type": "skos:Concept",
"properties": {"content": "A"}},
{"id": "http://example.org/B", "type": "skos:Concept",
"properties": {"content": "B"}},
], 2)
mock_session.get_edges.return_value = ([
{"source": "http://example.org/A", "target": "http://example.org/S",
"type": "skos:inScheme"},
{"source": "http://example.org/B", "target": "http://example.org/S",
"type": "skos:inScheme"},
# Cycle: A broader B AND B broader A
{"source": "http://example.org/A", "target": "http://example.org/B",
"type": "skos:broader"},
{"source": "http://example.org/B", "target": "http://example.org/A",
"type": "skos:broader"},
], 4)
response = client.get("/api/vocabulary/hierarchy?scheme=http://example.org/S")
# Must return 200 without hanging or raising a RecursionError
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# ---------------------------------------------------------------------------
# POST /api/vocabulary/import
# ---------------------------------------------------------------------------
MINIMAL_TTL = b"""
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix ex: <http://example.org/> .
ex:S a skos:ConceptScheme ; skos:prefLabel "S" .
"""
MINIMAL_RDF_XML = b"""<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:skos="http://www.w3.org/2004/02/skos/core#"
xmlns:ex="http://example.org/">
<skos:ConceptScheme rdf:about="http://example.org/SX">
<skos:prefLabel xml:lang="en">Scheme X</skos:prefLabel>
</skos:ConceptScheme>
</rdf:RDF>
"""
def test_import_ttl_success():
"""Valid .ttl upload returns success and calls add_nodes/add_edges."""
mock_session.add_nodes.return_value = 1
mock_session.add_edges.return_value = 0
response = client.post(
"/api/vocabulary/import",
files={"file": ("vocab.ttl", MINIMAL_TTL, "text/turtle")},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["filename"] == "vocab.ttl"
assert data["nodes_added"] == 1
assert data["edges_added"] == 0
mock_session.add_nodes.assert_called_once()
mock_session.add_edges.assert_called_once()
def test_import_rdf_xml_success():
""".rdf extension triggers XML format path."""
mock_session.add_nodes.return_value = 1
mock_session.add_edges.return_value = 0
response = client.post(
"/api/vocabulary/import",
files={"file": ("vocab.rdf", MINIMAL_RDF_XML, "application/rdf+xml")},
)
assert response.status_code == 200
assert response.json()["status"] == "success"
def test_import_invalid_file_returns_422():
"""Unparseable file content returns HTTP 422, not a silent 200 error dict."""
response = client.post(
"/api/vocabulary/import",
files={"file": ("bad.ttl", b"this is not valid RDF!", "text/turtle")},
)
assert response.status_code == 422
def test_import_owl_extension_uses_xml_format():
""".owl extension treated the same as .rdf — uses XML parser."""
mock_session.add_nodes.return_value = 1
mock_session.add_edges.return_value = 0
response = client.post(
"/api/vocabulary/import",
files={"file": ("onto.owl", MINIMAL_RDF_XML, "application/rdf+xml")},
)
assert response.status_code == 200
assert response.json()["status"] == "success"
+1 -1
View File
@@ -68,7 +68,7 @@ def test_sitemap_fallback_parsing() -> None:
):
urls = crawler.parse_sitemap("http://s.xml")
assert "http://a.com" in urls
assert any(url == "http://a.com" for url in urls)
def test_sitemap_invalid_xml() -> None:
+4 -1
View File
@@ -228,7 +228,10 @@ class TestCheckPolicy(unittest.TestCase):
def test_invalid_json_returns_error(self):
result = json.loads(self.kit.check_policy("{not valid json}"))
self.assertIn("error", result)
# Implementation returns {"compliant": False, "violations": [...], "warnings": [...]}
self.assertFalse(result["compliant"])
violations = result.get("violations", [])
self.assertGreater(len(violations), 0)
class TestGetDecisionSummary(unittest.TestCase):
+234
View File
@@ -210,5 +210,239 @@ class TestOntologyAdvanced(unittest.TestCase):
self.assertEqual(len(alignments), 1)
self.assertEqual(alignments[0]["target"], "http://target.org/2")
class TestSHACLHierarchicalAndValidation(unittest.TestCase):
"""Tests 17-34: Hierarchical inheritance, engine integration, and validation models."""
# 3-level hierarchy ontology: Animal → Dog → GuideDog
_HIER_ONTOLOGY = {
"classes": [
{"name": "Animal"},
{"name": "Dog", "parent": "Animal"},
{"name": "GuideDog", "parent": "Dog"},
],
"properties": [
{
"name": "name",
"type": "datatype",
"range": "string",
"domain": "Animal",
"required": True,
},
{
"name": "breed",
"type": "datatype",
"range": "string",
"domain": "Dog",
},
{
"name": "owner",
"type": "object",
"range": "Person",
"domain": "GuideDog",
"required": True,
},
],
}
def _make_gen(self, **kwargs):
from semantica.ontology.ontology_generator import SHACLGenerator
with patch(
"semantica.ontology.ontology_generator.get_logger",
return_value=MagicMock(),
), patch(
"semantica.ontology.ontology_generator.get_progress_tracker",
return_value=MagicMock(start_tracking=MagicMock(return_value="t")),
):
return SHACLGenerator(**kwargs)
# 17
def test_child_inherits_parent_property(self):
gen = self._make_gen(include_inherited=True)
graph = gen.generate(self._HIER_ONTOLOGY)
dog = next(ns for ns in graph.node_shapes if ns.target_class == "Dog")
paths = {ps.path for ps in dog.property_shapes}
self.assertIn("name", paths) # inherited from Animal
self.assertIn("breed", paths) # own
# 18
def test_grandchild_inherits_all_ancestors(self):
gen = self._make_gen(include_inherited=True)
graph = gen.generate(self._HIER_ONTOLOGY)
gd = next(ns for ns in graph.node_shapes if ns.target_class == "GuideDog")
paths = {ps.path for ps in gd.property_shapes}
self.assertIn("name", paths) # from Animal
self.assertIn("breed", paths) # from Dog
self.assertIn("owner", paths) # own
# 19
def test_no_inheritance_when_disabled(self):
gen = self._make_gen(include_inherited=False)
graph = gen.generate(self._HIER_ONTOLOGY)
dog = next(ns for ns in graph.node_shapes if ns.target_class == "Dog")
paths = {ps.path for ps in dog.property_shapes}
self.assertNotIn("name", paths) # parent property should NOT appear
# 20
def test_no_duplicate_shapes_after_inheritance(self):
gen = self._make_gen(include_inherited=True)
graph = gen.generate(self._HIER_ONTOLOGY)
for node_shape in graph.node_shapes:
paths = [ps.path for ps in node_shape.property_shapes]
self.assertEqual(len(paths), len(set(paths)),
f"Duplicate paths in {node_shape.target_class}: {paths}")
# 21
def test_no_domain_property_attaches_to_all_shapes(self):
onto = {
"classes": [{"name": "A"}, {"name": "B"}],
"properties": [
{"name": "globalProp", "type": "datatype", "range": "string"}
# no domain
],
}
gen = self._make_gen()
graph = gen.generate(onto)
for node_shape in graph.node_shapes:
paths = {ps.path for ps in node_shape.property_shapes}
self.assertIn("globalProp", paths)
# 22
def test_empty_classes_produces_no_shapes(self):
gen = self._make_gen()
graph = gen.generate({"classes": [], "properties": []})
self.assertEqual(len(graph.node_shapes), 0)
# 23
def test_sh_prefix_always_present(self):
gen = self._make_gen()
graph = gen.generate(self._HIER_ONTOLOGY)
self.assertIn("sh", graph.prefixes)
self.assertIn("shacl#", graph.prefixes["sh"])
# 24
def test_custom_base_uri(self):
gen = self._make_gen(base_uri="https://myorg.com/shapes/")
graph = gen.generate(self._HIER_ONTOLOGY)
ttl = gen.serialize(graph, format="turtle")
self.assertIn("myorg.com", ttl)
# 25
def test_severity_warning(self):
gen = self._make_gen(severity="Warning")
graph = gen.generate(self._HIER_ONTOLOGY)
ttl = gen.serialize(graph, format="turtle")
self.assertIn("sh:Warning", ttl)
self.assertNotIn("sh:Violation", ttl)
# 26
def test_strict_tier_sets_closed(self):
gen = self._make_gen(quality_tier="strict")
graph = gen.generate(self._HIER_ONTOLOGY)
# Shapes with property_shapes should be closed
for node_shape in graph.node_shapes:
if node_shape.property_shapes:
self.assertTrue(node_shape.closed,
f"{node_shape.target_class}Shape should be closed")
# 27
def test_strict_tier_includes_ignored_properties(self):
gen = self._make_gen(quality_tier="strict")
graph = gen.generate(self._HIER_ONTOLOGY)
ttl = gen.serialize(graph, format="turtle")
self.assertIn("sh:ignoredProperties", ttl)
# 28
def test_engine_to_shacl_returns_non_empty_string(self):
mock_progress = MagicMock()
mock_progress.start_tracking.return_value = "tid"
with patch("semantica.ontology.engine.get_logger", return_value=MagicMock()), \
patch("semantica.ontology.engine.get_progress_tracker", return_value=mock_progress), \
patch("semantica.ontology.ontology_generator.get_logger", return_value=MagicMock()), \
patch("semantica.ontology.ontology_generator.get_progress_tracker", return_value=mock_progress):
from semantica.ontology.engine import OntologyEngine
engine = OntologyEngine()
result = engine.to_shacl(self._HIER_ONTOLOGY)
self.assertIsInstance(result, str)
self.assertGreater(len(result), 0)
self.assertIn("sh:NodeShape", result)
# 29
def test_engine_to_shacl_jsonld(self):
import json
mock_progress = MagicMock()
mock_progress.start_tracking.return_value = "tid"
with patch("semantica.ontology.engine.get_logger", return_value=MagicMock()), \
patch("semantica.ontology.engine.get_progress_tracker", return_value=mock_progress), \
patch("semantica.ontology.ontology_generator.get_logger", return_value=MagicMock()), \
patch("semantica.ontology.ontology_generator.get_progress_tracker", return_value=mock_progress):
from semantica.ontology.engine import OntologyEngine
engine = OntologyEngine()
result = engine.to_shacl(self._HIER_ONTOLOGY, format="json-ld")
parsed = json.loads(result)
self.assertIn("@graph", parsed)
# 30
def test_shacl_validation_report_summary_conforms(self):
from semantica.ontology.ontology_validator import SHACLValidationReport
report = SHACLValidationReport(conforms=True)
self.assertIn("conforms", report.summary().lower())
# 31
def test_shacl_validation_report_summary_violations(self):
from semantica.ontology.ontology_validator import (
SHACLValidationReport,
SHACLViolation,
)
v = SHACLViolation(focus_node="https://example.com/node1")
report = SHACLValidationReport(conforms=False, violations=[v])
self.assertIn("1 violation", report.summary())
# 32
def test_explain_violations_populates_explanation(self):
from semantica.ontology.ontology_validator import (
SHACLValidationReport,
SHACLViolation,
)
v = SHACLViolation(
focus_node="https://example.com/john",
result_path="ex:name",
constraint="MinCountConstraintComponent",
)
report = SHACLValidationReport(conforms=False, violations=[v])
report.explain_violations()
self.assertIsNotNone(v.explanation)
self.assertIn("https://example.com/john", v.explanation)
# 33
def test_shacl_violation_to_dict(self):
from semantica.ontology.ontology_validator import SHACLViolation
v = SHACLViolation(
focus_node="https://example.com/n",
constraint="DatatypeConstraintComponent",
explanation="some explanation",
)
d = v.to_dict()
self.assertIn("focus_node", d)
self.assertIn("constraint", d)
self.assertIn("explanation", d)
# 34
def test_validation_report_to_dict_structure(self):
from semantica.ontology.ontology_validator import (
SHACLValidationReport,
SHACLViolation,
)
v = SHACLViolation(focus_node="https://example.com/x")
report = SHACLValidationReport(conforms=False, violations=[v])
d = report.to_dict()
self.assertIn("conforms", d)
self.assertIn("violations", d)
self.assertIn("warnings", d)
self.assertIn("violation_count", d)
self.assertEqual(d["violation_count"], 1)
self.assertFalse(d["conforms"])
if __name__ == '__main__':
unittest.main()
@@ -243,5 +243,387 @@ class TestOntologyComprehensive(unittest.TestCase):
self.assertEqual(mod.name, "PersonModule")
self.assertIn("Person", mod.classes)
class TestSHACLGeneration(unittest.TestCase):
"""Tests 1-16: SHACL shape generation from flat ontologies."""
# Shared flat ontology fixture
_ONTOLOGY = {
"classes": [
{"name": "Person", "label": "Person", "description": "A human individual"},
{"name": "Organization", "label": "Organization"},
],
"properties": [
{
"name": "name",
"type": "datatype",
"range": "string",
"domain": "Person",
"required": True,
},
{
"name": "age",
"type": "datatype",
"range": "integer",
"domain": "Person",
"cardinality": {"min": 0, "max": 1},
},
{
"name": "worksFor",
"type": "object",
"range": "Organization",
"domain": "Person",
},
{
"name": "legalName",
"type": "datatype",
"range": "string",
"domain": "Organization",
"required": True,
},
],
}
def setUp(self):
self.mock_logger = MagicMock()
self.mock_tracker = MagicMock()
self.mock_tracker.start_tracking.return_value = "track_shacl"
self.patchers = [
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,
),
]
for p in self.patchers:
p.start()
from semantica.ontology.ontology_generator import SHACLGenerator
self.gen = SHACLGenerator(
base_uri="https://semantica.dev/shapes/",
quality_tier="standard",
)
def tearDown(self):
for p in self.patchers:
p.stop()
# 1
def test_generate_returns_shacl_graph(self):
from semantica.ontology.ontology_generator import SHACLGraph
graph = self.gen.generate(self._ONTOLOGY)
self.assertIsInstance(graph, SHACLGraph)
# 2
def test_node_shape_count_matches_class_count(self):
graph = self.gen.generate(self._ONTOLOGY)
self.assertEqual(len(graph.node_shapes), 2)
# 3
def test_node_shape_target_classes(self):
graph = self.gen.generate(self._ONTOLOGY)
classes = {ns.target_class for ns in graph.node_shapes}
self.assertIn("Person", classes)
self.assertIn("Organization", classes)
# 4
def test_required_property_gets_min_count_1(self):
graph = self.gen.generate(self._ONTOLOGY)
person = next(ns for ns in graph.node_shapes if ns.target_class == "Person")
name_ps = next(ps for ps in person.property_shapes if ps.path == "name")
self.assertEqual(name_ps.min_count, 1)
# 5
def test_cardinality_min_max(self):
graph = self.gen.generate(self._ONTOLOGY)
person = next(ns for ns in graph.node_shapes if ns.target_class == "Person")
age_ps = next(ps for ps in person.property_shapes if ps.path == "age")
self.assertEqual(age_ps.min_count, 0)
self.assertEqual(age_ps.max_count, 1)
# 6
def test_datatype_property_gets_xsd_datatype(self):
graph = self.gen.generate(self._ONTOLOGY)
person = next(ns for ns in graph.node_shapes if ns.target_class == "Person")
name_ps = next(ps for ps in person.property_shapes if ps.path == "name")
self.assertEqual(name_ps.datatype, "xsd:string")
self.assertIsNone(name_ps.class_)
# 7
def test_object_property_gets_sh_class(self):
graph = self.gen.generate(self._ONTOLOGY)
person = next(ns for ns in graph.node_shapes if ns.target_class == "Person")
wf_ps = next(ps for ps in person.property_shapes if ps.path == "worksFor")
self.assertEqual(wf_ps.class_, "Organization")
self.assertIsNone(wf_ps.datatype)
# 8
def test_turtle_contains_sh_node_shape(self):
graph = self.gen.generate(self._ONTOLOGY)
ttl = self.gen.serialize(graph, format="turtle")
self.assertIn("sh:NodeShape", ttl)
self.assertIn("sh:targetClass", ttl)
self.assertIn("sh:property", ttl)
# 9
def test_jsonld_is_valid_json(self):
import json
graph = self.gen.generate(self._ONTOLOGY)
jld = self.gen.serialize(graph, format="json-ld")
parsed = json.loads(jld)
self.assertIn("@context", parsed)
self.assertIn("@graph", parsed)
# 10
def test_ntriples_uses_expanded_uris(self):
graph = self.gen.generate(self._ONTOLOGY)
nt = self.gen.serialize(graph, format="n-triples")
self.assertNotIn("@prefix", nt)
self.assertIn("<http://www.w3.org/ns/shacl#NodeShape>", nt)
# 11
def test_unknown_format_raises_value_error(self):
graph = self.gen.generate(self._ONTOLOGY)
with self.assertRaises(ValueError):
self.gen.serialize(graph, format="csv")
# 12
def test_non_dict_ontology_raises_value_error(self):
with self.assertRaises(ValueError):
self.gen.generate("not a dict")
# 13
def test_ontology_missing_both_keys_raises_value_error(self):
with self.assertRaises(ValueError):
self.gen.generate({"namespace": {}})
# 14
def test_enumeration_produces_sh_in(self):
onto = {
"classes": [{"name": "Order"}],
"properties": [
{
"name": "status",
"type": "datatype",
"range": "string",
"domain": "Order",
"one_of": ["pending", "shipped", "delivered", "cancelled"],
}
],
}
graph = self.gen.generate(onto)
ttl = self.gen.serialize(graph, format="turtle")
self.assertIn("sh:in", ttl)
self.assertIn('"pending"', ttl)
# 15
def test_custom_namespace_in_prefixes(self):
onto = dict(self._ONTOLOGY)
onto["namespace"] = {"base_uri": "https://custom.org/onto/"}
graph = self.gen.generate(onto)
self.assertIn("https://custom.org/onto/", graph.prefixes.values())
# 16
def test_standard_tier_is_default(self):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator()
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()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,971 @@
"""
Comprehensive tests for ALL features listed in the [Unreleased] section of CHANGELOG.md.
Covers gaps not addressed by existing test files:
PR #399 — AgentContext: checkpoint(), diff_checkpoints(), flush_checkpoint()
PR #394 — TemporalVersionManager: attach_to_graph(), tag_version(), list_tags(),
diff() alias, get_node_history(), restore_snapshot() rollback protection
PR #393 — Snapshot schema compatibility: nodes/edges ↔ entities/relationships
PR #385 — ContextGraph pagination: skip parameter, min_weight neighbor filter
PR #385 — ContextGraph thread safety: concurrent mutations
PR #319 — SKOS Vocabulary Module: namespace helpers, OntologyEngine APIs,
TripletStore helpers (gap tests beyond existing suite)
PR #318 — SHACL: quality tiers, export_shacl, RDFExporter.export_shacl (gap tests)
PR #408 — OllamaProvider base_url fix (gap tests beyond existing suite)
PR #371 — DatalogReasoner: idempotency, cache flag, graph load (gap tests)
"""
from __future__ import annotations
import threading
import time
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
UTC = timezone.utc
def _utc(year: int, month: int = 1, day: int = 1) -> datetime:
return datetime(year, month, day, tzinfo=UTC)
# ===========================================================================
# PR #399 — AgentContext: checkpoint / diff_checkpoints / flush_checkpoint
# ===========================================================================
class TestAgentContextCheckpoint:
"""checkpoint() captures the current graph state under a label."""
@pytest.fixture
def ctx(self):
from semantica.context import AgentContext, ContextGraph
graph = ContextGraph()
mock_vs = MagicMock()
mock_vs.search.return_value = []
return AgentContext(
vector_store=mock_vs,
knowledge_graph=graph,
decision_tracking=True,
), graph
def test_checkpoint_returns_dict(self, ctx):
context, _ = ctx
snap = context.checkpoint("snap1")
assert isinstance(snap, dict)
def test_checkpoint_has_timestamp(self, ctx):
context, _ = ctx
snap = context.checkpoint("snap1")
assert "timestamp" in snap
def test_checkpoint_empty_graph_has_no_nodes(self, ctx):
context, _ = ctx
snap = context.checkpoint("empty")
assert snap.get("nodes", []) == [] or snap.get("entities", []) == []
def test_checkpoint_captures_added_node(self, ctx):
context, graph = ctx
graph.add_node("n1", "entity", content="hello")
snap = context.checkpoint("after")
node_ids = {n["id"] for n in snap.get("nodes", snap.get("entities", []))}
assert "n1" in node_ids
def test_checkpoint_second_call_overwrites_label(self, ctx):
context, graph = ctx
context.checkpoint("label")
graph.add_node("n2", "entity", content="new")
snap2 = context.checkpoint("label")
node_ids = {n["id"] for n in snap2.get("nodes", snap2.get("entities", []))}
assert "n2" in node_ids
def test_checkpoint_independent_of_subsequent_changes(self, ctx):
context, graph = ctx
context.checkpoint("before")
graph.add_node("n_after", "entity", content="added later")
snap_before = context._checkpoints["before"]
node_ids = {n["id"] for n in snap_before.get("nodes", snap_before.get("entities", []))}
assert "n_after" not in node_ids
class TestAgentContextDiffCheckpoints:
"""diff_checkpoints() computes the structural delta between two checkpoints."""
@pytest.fixture
def ctx_with_checkpoints(self):
from semantica.context import AgentContext, ContextGraph
graph = ContextGraph()
mock_vs = MagicMock()
mock_vs.search.return_value = []
context = AgentContext(
vector_store=mock_vs,
knowledge_graph=graph,
decision_tracking=True,
)
context.checkpoint("before")
did = context.record_decision(
category="policy",
scenario="new scenario",
reasoning="because",
outcome="approved",
confidence=0.9,
)
graph.add_node("entity_x", "entity", content="X")
graph.add_edge(did, "entity_x", "involves")
context.checkpoint("after")
return context, graph, did
def test_diff_has_required_keys(self, ctx_with_checkpoints):
context, _, _ = ctx_with_checkpoints
diff = context.diff_checkpoints("before", "after")
for key in ("decisions_added", "decisions_removed", "relationships_added", "relationships_removed"):
assert key in diff
def test_decisions_added_contains_new_decision(self, ctx_with_checkpoints):
context, _, did = ctx_with_checkpoints
diff = context.diff_checkpoints("before", "after")
assert any(item["id"] == did for item in diff["decisions_added"])
def test_decisions_removed_is_empty_when_nothing_removed(self, ctx_with_checkpoints):
context, _, _ = ctx_with_checkpoints
diff = context.diff_checkpoints("before", "after")
assert diff["decisions_removed"] == []
def test_relationships_added_contains_new_edge(self, ctx_with_checkpoints):
context, _, did = ctx_with_checkpoints
diff = context.diff_checkpoints("before", "after")
assert any(item["type"] == "involves" for item in diff["relationships_added"])
def test_diff_reversed_shows_decision_removed(self, ctx_with_checkpoints):
context, _, did = ctx_with_checkpoints
# "after" → "before" is a rewind: decision should appear as removed
diff = context.diff_checkpoints("after", "before")
assert any(item["id"] == did for item in diff["decisions_removed"])
def test_diff_same_snapshot_all_empty(self, ctx_with_checkpoints):
context, _, _ = ctx_with_checkpoints
diff = context.diff_checkpoints("after", "after")
assert diff["decisions_added"] == []
assert diff["decisions_removed"] == []
def test_unknown_first_label_raises_key_error(self, ctx_with_checkpoints):
context, _, _ = ctx_with_checkpoints
with pytest.raises(KeyError):
context.diff_checkpoints("ghost", "after")
def test_unknown_second_label_raises_key_error(self, ctx_with_checkpoints):
context, _, _ = ctx_with_checkpoints
with pytest.raises(KeyError):
context.diff_checkpoints("before", "ghost")
def test_both_labels_unknown_raises_key_error(self):
from semantica.context import AgentContext, ContextGraph
mock_vs = MagicMock()
mock_vs.search.return_value = []
context = AgentContext(vector_store=mock_vs, knowledge_graph=ContextGraph())
with pytest.raises(KeyError):
context.diff_checkpoints("x", "y")
class TestAgentContextFlushCheckpoint:
"""flush_checkpoint() persists a named checkpoint via TemporalVersionManager."""
@pytest.fixture
def ctx(self):
from semantica.context import AgentContext, ContextGraph
graph = ContextGraph()
mock_vs = MagicMock()
mock_vs.search.return_value = []
return AgentContext(
vector_store=mock_vs,
knowledge_graph=graph,
decision_tracking=True,
)
def test_flush_returns_snapshot_dict(self, ctx):
ctx.checkpoint("v1")
result = ctx.flush_checkpoint("v1")
assert isinstance(result, dict)
assert result["label"] == "v1"
def test_flush_snapshot_has_both_schema_keys(self, ctx):
# flush_checkpoint uses change_management.TemporalVersionManager which
# stores both "nodes"/"edges" and "entities"/"relationships" keys.
ctx.checkpoint("v1")
result = ctx.flush_checkpoint("v1")
assert "entities" in result or "nodes" in result
def test_flush_snapshot_has_checksum(self, ctx):
ctx.checkpoint("v1")
result = ctx.flush_checkpoint("v1")
assert "checksum" in result
def test_flush_unknown_label_raises_key_error(self, ctx):
with pytest.raises(KeyError):
ctx.flush_checkpoint("nonexistent")
def test_flush_can_be_retrieved_from_version_manager(self, ctx):
from semantica.kg.temporal_query import TemporalVersionManager
manager = TemporalVersionManager()
ctx._temporal_version_manager = manager
ctx.checkpoint("release-1")
ctx.flush_checkpoint("release-1")
retrieved = manager.get_version("release-1")
assert retrieved is not None
assert retrieved["label"] == "release-1"
def test_multiple_checkpoints_flushed_independently(self, ctx):
from semantica.context import ContextGraph
from semantica.kg.temporal_query import TemporalVersionManager
manager = TemporalVersionManager()
ctx._temporal_version_manager = manager
ctx.checkpoint("snap-a")
ctx.checkpoint("snap-b")
ctx.flush_checkpoint("snap-a")
ctx.flush_checkpoint("snap-b")
assert manager.get_version("snap-a") is not None
assert manager.get_version("snap-b") is not None
# ===========================================================================
# PR #394 — Audit Trail, Named Tags, diff() alias, rollback protection
# ===========================================================================
class TestAuditTrailAdditional:
"""Additional coverage for PR #394 audit-trail features."""
@pytest.fixture
def setup(self):
from semantica.context import ContextGraph
from semantica.change_management.managers import TemporalVersionManager
graph = ContextGraph()
manager = TemporalVersionManager()
manager.attach_to_graph(graph)
return graph, manager
def test_attach_to_graph_sets_mutation_callback(self, setup):
graph, manager = setup
assert callable(getattr(graph, "mutation_callback", None))
def test_add_node_creates_history_entry(self, setup):
graph, manager = setup
graph.add_node("n1", "entity", content="test")
history = manager.get_node_history("n1")
assert len(history) >= 1
assert history[0]["operation"] == "ADD_NODE"
def test_update_node_creates_second_entry(self, setup):
graph, manager = setup
graph.add_node("n1", "entity", content="initial")
graph.add_node_attribute("n1", {"key": "val"})
history = manager.get_node_history("n1")
operations = [h["operation"] for h in history]
assert "ADD_NODE" in operations
assert "UPDATE_NODE" in operations
def test_get_node_history_returns_empty_for_unknown_node(self, setup):
_, manager = setup
assert manager.get_node_history("does_not_exist") == []
def test_multiple_nodes_tracked_independently(self, setup):
graph, manager = setup
graph.add_node("a", "entity")
graph.add_node("b", "entity")
graph.add_node_attribute("a", {"x": 1})
assert len(manager.get_node_history("a")) == 2
assert len(manager.get_node_history("b")) == 1
class TestNamedTagsAdditional:
"""Additional coverage for named version tags from PR #394."""
@pytest.fixture
def setup(self):
from semantica.context import ContextGraph
from semantica.change_management.managers import TemporalVersionManager
graph = ContextGraph()
manager = TemporalVersionManager()
graph.add_node("n1", "entity")
snap = manager.create_snapshot(
graph.to_dict(),
version_label="v1.0",
author="user@example.com",
description="First",
)
return manager
def test_list_tags_empty_initially(self):
from semantica.change_management.managers import TemporalVersionManager
manager = TemporalVersionManager()
assert manager.list_tags() == {}
def test_tag_version_and_retrieve(self, setup):
manager = setup
manager.tag_version("v1.0", "stable")
tags = manager.list_tags()
assert "stable" in tags
assert tags["stable"] == "v1.0"
def test_multiple_tags_on_same_version(self, setup):
manager = setup
manager.tag_version("v1.0", "production")
manager.tag_version("v1.0", "latest")
tags = manager.list_tags()
assert tags["production"] == "v1.0"
assert tags["latest"] == "v1.0"
def test_tag_nonexistent_version_raises(self):
from semantica.change_management.managers import TemporalVersionManager
manager = TemporalVersionManager()
with pytest.raises(Exception):
manager.tag_version("ghost", "my-tag")
def test_diff_alias_equivalent_to_compare_versions(self, setup):
from semantica.context import ContextGraph
manager = setup
graph2 = ContextGraph()
graph2.add_node("n1", "entity")
graph2.add_node("n2", "entity")
manager.create_snapshot(
graph2.to_dict(),
version_label="v2.0",
author="user@example.com",
description="Second",
)
diff_result = manager.diff("v1.0", "v2.0")
compare_result = manager.compare_versions("v1.0", "v2.0")
# Both should return the same structure
assert set(diff_result.keys()) == set(compare_result.keys())
def test_diff_alias_shows_added_entity(self, setup):
from semantica.context import ContextGraph
manager = setup
graph2 = ContextGraph()
graph2.add_node("n1", "entity")
graph2.add_node("n2", "entity") # added
manager.create_snapshot(
graph2.to_dict(),
version_label="v2.0",
author="user@example.com",
description="Second",
)
diff = manager.diff("v1.0", "v2.0")
assert diff["summary"]["entities_added"] >= 1
class TestRollbackProtectionAdditional:
"""Additional rollback protection edge cases from PR #394."""
@pytest.fixture
def setup_with_snapshot(self):
from semantica.context import ContextGraph
from semantica.change_management.managers import TemporalVersionManager
graph = ContextGraph()
graph.add_node("n1", "entity", content="original")
manager = TemporalVersionManager()
manager.attach_to_graph(graph)
manager.create_snapshot(
graph.to_dict(),
version_label="v1.0",
author="user@example.com",
description="Original",
)
return graph, manager
def test_restore_requires_confirmation_by_default(self, setup_with_snapshot):
from semantica.change_management.managers import ProcessingError
graph, manager = setup_with_snapshot
with pytest.raises(ProcessingError, match="Rollback protection"):
manager.restore_snapshot(graph, "v1.0")
def test_restore_succeeds_with_confirmation_false(self, setup_with_snapshot):
graph, manager = setup_with_snapshot
result = manager.restore_snapshot(graph, "v1.0", require_confirmation=False)
assert result is True
def test_restore_to_nonexistent_version_raises(self, setup_with_snapshot):
graph, manager = setup_with_snapshot
from semantica.utils.exceptions import ValidationError
with pytest.raises(ValidationError):
manager.restore_snapshot(graph, "ghost", require_confirmation=False)
def test_restore_replay_does_not_add_to_audit_log(self, setup_with_snapshot):
graph, manager = setup_with_snapshot
graph.add_node_attribute("n1", {"status": "modified"})
history_before = manager.get_node_history("n1")
count_before = len(history_before)
manager.restore_snapshot(graph, "v1.0", require_confirmation=False)
history_after = manager.get_node_history("n1")
# Restore must not record new mutations
assert len(history_after) == count_before
# ===========================================================================
# PR #393 — Snapshot Schema Compatibility
# ===========================================================================
class TestSnapshotSchemaCompatibility:
"""TemporalVersionManager must accept both nodes/edges and entities/relationships."""
@pytest.fixture
def manager(self):
from semantica.kg.temporal_query import TemporalVersionManager
return TemporalVersionManager()
def test_create_snapshot_with_nodes_edges_schema(self, manager):
graph = {
"nodes": [{"id": "1", "type": "Person"}],
"edges": [{"source": "1", "target": "2", "type": "knows"}],
}
snap = manager.create_snapshot(graph, "v-ne", "user@x.com", "nodes/edges schema")
assert snap["label"] == "v-ne"
def test_create_snapshot_with_entities_relationships_schema(self, manager):
graph = {
"entities": [{"id": "1", "type": "Person"}],
"relationships": [{"source": "1", "target": "2", "type": "knows"}],
}
snap = manager.create_snapshot(graph, "v-er", "user@x.com", "entities/rels schema")
assert snap["label"] == "v-er"
def test_validate_snapshot_nodes_edges_true(self, manager):
graph = {
"nodes": [{"id": "1"}],
"edges": [],
}
snap = manager.create_snapshot(graph, "v1", "user@x.com", "test")
assert manager.validate_snapshot(snap) is True
def test_compare_versions_nodes_edges_schema(self, manager):
# kg.temporal_query.TemporalVersionManager accepts nodes/edges schema
# without error; compare_versions must not raise.
g1 = {"nodes": [{"id": "A"}], "edges": []}
g2 = {"nodes": [{"id": "A"}, {"id": "B"}], "edges": []}
manager.create_snapshot(g1, "old", "u@x.com", "old")
manager.create_snapshot(g2, "new", "u@x.com", "new")
diff = manager.compare_versions("old", "new")
assert "summary" in diff
def test_compare_versions_entities_rels_schema(self, manager):
g1 = {"entities": [{"id": "A"}], "relationships": []}
g2 = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []}
manager.create_snapshot(g1, "old2", "u@x.com", "old")
manager.create_snapshot(g2, "new2", "u@x.com", "new")
diff = manager.compare_versions("old2", "new2")
assert diff["summary"]["entities_added"] >= 1
def test_mixed_schema_compare_does_not_crash(self, manager):
g1 = {"nodes": [{"id": "A"}], "edges": []}
g2 = {"entities": [{"id": "A"}, {"id": "B"}], "relationships": []}
manager.create_snapshot(g1, "mix1", "u@x.com", "nodes schema")
manager.create_snapshot(g2, "mix2", "u@x.com", "entities schema")
# Must not raise regardless of schema mismatch
diff = manager.compare_versions("mix1", "mix2")
assert "summary" in diff
def test_snapshot_format_version_stamped_regardless_of_schema(self, manager):
for schema, label in [
({"nodes": [], "edges": []}, "ne"),
({"entities": [], "relationships": []}, "er"),
]:
snap = manager.create_snapshot(schema, label, "u@x.com", "test")
assert snap.get("format_version") == "1.0"
# ===========================================================================
# PR #385 — ContextGraph Pagination: skip parameter
# ===========================================================================
class TestContextGraphPaginationSkip:
"""find_nodes / find_edges / find_active_nodes must honour the skip parameter."""
@pytest.fixture
def graph_with_nodes(self):
from semantica.context import ContextGraph
g = ContextGraph()
for i in range(6):
g.add_node(f"n{i}", "entity", content=str(i))
return g
@pytest.fixture
def graph_with_edges(self):
from semantica.context import ContextGraph
g = ContextGraph()
for i in range(6):
g.add_node(f"n{i}", "entity")
for i in range(5):
g.add_edge(f"n{i}", f"n{i+1}", "next")
return g
# find_nodes
def test_find_nodes_skip_zero_returns_all(self, graph_with_nodes):
result = graph_with_nodes.find_nodes(skip=0)
assert len(result) == 6
def test_find_nodes_skip_positive_reduces_count(self, graph_with_nodes):
result = graph_with_nodes.find_nodes(skip=2)
assert len(result) == 4
def test_find_nodes_skip_and_limit_window(self, graph_with_nodes):
result = graph_with_nodes.find_nodes(skip=2, limit=2)
assert len(result) == 2
def test_find_nodes_skip_beyond_length_returns_empty(self, graph_with_nodes):
result = graph_with_nodes.find_nodes(skip=100)
assert result == []
def test_find_nodes_skip_plus_limit_no_overlap_with_first_page(self, graph_with_nodes):
page1 = graph_with_nodes.find_nodes(skip=0, limit=3)
page2 = graph_with_nodes.find_nodes(skip=3, limit=3)
ids1 = {n["id"] for n in page1}
ids2 = {n["id"] for n in page2}
assert ids1.isdisjoint(ids2)
assert ids1 | ids2 == {f"n{i}" for i in range(6)}
# find_edges
def test_find_edges_skip_zero_returns_all(self, graph_with_edges):
result = graph_with_edges.find_edges(skip=0)
assert len(result) == 5
def test_find_edges_skip_reduces_count(self, graph_with_edges):
result = graph_with_edges.find_edges(skip=2)
assert len(result) == 3
def test_find_edges_skip_and_limit(self, graph_with_edges):
result = graph_with_edges.find_edges(skip=1, limit=2)
assert len(result) == 2
def test_find_edges_skip_beyond_returns_empty(self, graph_with_edges):
result = graph_with_edges.find_edges(skip=100)
assert result == []
def test_find_edges_pagination_covers_all(self, graph_with_edges):
page1 = graph_with_edges.find_edges(skip=0, limit=3)
page2 = graph_with_edges.find_edges(skip=3, limit=3)
combined = len(page1) + len(page2)
assert combined == 5
# find_active_nodes
def test_find_active_nodes_skip_zero_returns_all(self, graph_with_nodes):
result = graph_with_nodes.find_active_nodes(skip=0)
assert len(result) == 6
def test_find_active_nodes_skip_reduces_count(self, graph_with_nodes):
result = graph_with_nodes.find_active_nodes(skip=3)
assert len(result) == 3
def test_find_active_nodes_skip_and_limit(self, graph_with_nodes):
result = graph_with_nodes.find_active_nodes(skip=2, limit=2)
assert len(result) == 2
class TestContextGraphMinWeightNeighborFilter:
"""get_neighbors(min_weight=N) from PR #385 filters out low-weight edges."""
@pytest.fixture
def weighted_graph(self):
from semantica.context import ContextGraph
g = ContextGraph()
g.add_node("center", "entity")
g.add_node("heavy", "entity")
g.add_node("light", "entity")
g.add_node("zero", "entity")
g.add_edge("center", "heavy", "link", weight=0.9)
g.add_edge("center", "light", "link", weight=0.2)
g.add_edge("center", "zero", "link", weight=0.0)
return g
def test_no_min_weight_returns_all_neighbors(self, weighted_graph):
result = weighted_graph.get_neighbors("center")
ids = {n["id"] for n in result}
assert ids == {"heavy", "light", "zero"}
def test_min_weight_filters_low_weight_edges(self, weighted_graph):
result = weighted_graph.get_neighbors("center", min_weight=0.5)
ids = {n["id"] for n in result}
assert "heavy" in ids
assert "light" not in ids
assert "zero" not in ids
def test_min_weight_zero_returns_all(self, weighted_graph):
result = weighted_graph.get_neighbors("center", min_weight=0.0)
assert len(result) == 3
def test_min_weight_one_returns_none(self, weighted_graph):
result = weighted_graph.get_neighbors("center", min_weight=1.0)
assert result == []
def test_min_weight_exact_boundary_inclusive(self, weighted_graph):
# edge to "heavy" has weight=0.9; min_weight=0.9 should include it
result = weighted_graph.get_neighbors("center", min_weight=0.9)
ids = {n["id"] for n in result}
assert "heavy" in ids
# ===========================================================================
# PR #385 — ContextGraph Thread Safety
# ===========================================================================
class TestContextGraphThreadSafety:
"""ContextGraph must be safe for concurrent reads and writes."""
def test_concurrent_add_node_no_corruption(self):
from semantica.context import ContextGraph
graph = ContextGraph()
errors = []
def add_nodes(start: int):
try:
for i in range(start, start + 20):
graph.add_node(f"n-{i}", "entity", content=str(i))
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=add_nodes, args=(i * 20,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread errors: {errors}"
assert len(graph.nodes) == 100
def test_concurrent_reads_while_writing(self):
from semantica.context import ContextGraph
graph = ContextGraph()
for i in range(20):
graph.add_node(f"initial-{i}", "entity")
errors = []
def reader():
try:
for _ in range(50):
_ = graph.find_nodes()
except Exception as exc:
errors.append(exc)
def writer():
try:
for i in range(50):
graph.add_node(f"w-{threading.get_ident()}-{i}", "entity")
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=reader) for _ in range(3)] + \
[threading.Thread(target=writer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread errors: {errors}"
def test_concurrent_add_edge_no_corruption(self):
from semantica.context import ContextGraph
graph = ContextGraph()
for i in range(40):
graph.add_node(f"n{i}", "entity")
errors = []
def add_edges(offset: int):
try:
for i in range(offset, offset + 10):
graph.add_edge(f"n{i}", f"n{i+1}", "link")
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=add_edges, args=(i * 10,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread errors: {errors}"
def test_find_nodes_consistent_under_concurrent_writes(self):
from semantica.context import ContextGraph
graph = ContextGraph()
results = []
errors = []
def writer():
for i in range(30):
graph.add_node(f"wt-{threading.get_ident()}-{i}", "entity")
def reader():
try:
for _ in range(10):
snapshot = graph.find_nodes()
results.append(len(snapshot))
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=writer) for _ in range(3)] + \
[threading.Thread(target=reader) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == [], f"Thread errors: {errors}"
# All snapshots must be non-negative integers (no partial-write corruption)
assert all(r >= 0 for r in results)
# ===========================================================================
# PR #319 — SKOS Vocabulary Module: namespace helpers (gap tests)
# ===========================================================================
class TestSKOSNamespaceHelpers:
"""get_skos_uri and build_concept_scheme_uri gap tests beyond existing suite."""
@pytest.fixture
def nm(self):
from semantica.ontology.namespace_manager import NamespaceManager
return NamespaceManager()
def test_get_skos_uri_prefLabel(self, nm):
uri = nm.get_skos_uri("prefLabel")
assert uri == "http://www.w3.org/2004/02/skos/core#prefLabel"
def test_get_skos_uri_Concept(self, nm):
uri = nm.get_skos_uri("Concept")
assert "Concept" in uri
assert uri.startswith("http://www.w3.org/2004/02/skos/core#")
def test_get_skos_uri_broader(self, nm):
uri = nm.get_skos_uri("broader")
assert uri.endswith("#broader")
def test_build_concept_scheme_uri_lowercases(self, nm):
uri = nm.build_concept_scheme_uri("My Vocabulary")
assert "my-vocabulary" in uri.lower()
def test_build_concept_scheme_uri_replaces_spaces_with_hyphens(self, nm):
uri = nm.build_concept_scheme_uri("Drug Interaction Terms")
assert " " not in uri
def test_build_concept_scheme_uri_contains_vocab_segment(self, nm):
uri = nm.build_concept_scheme_uri("Test")
assert "/vocab/" in uri
def test_build_concept_scheme_uri_special_chars_normalised(self, nm):
uri = nm.build_concept_scheme_uri("A&B!Vocab")
assert "&" not in uri
assert "!" not in uri
# ===========================================================================
# PR #318 — SHACL: quality tiers and export (gap tests)
# ===========================================================================
class TestSHACLQualityTiersGap:
"""Quality tier differences between basic / standard / strict."""
@pytest.fixture
def generator(self):
from semantica.ontology.ontology_generator import SHACLGenerator
return SHACLGenerator()
@pytest.fixture
def simple_ontology(self):
# SHACLGenerator expects classes and top-level properties (with domain)
return {
"classes": [{"name": "Person"}],
"properties": [
{"name": "name", "domain": "Person", "range": "string"},
{"name": "age", "domain": "Person", "range": "integer"},
],
}
def test_basic_tier_produces_output(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator(quality_tier="basic")
result = gen.generate(simple_ontology)
assert result is not None
assert len(gen.serialize(result)) > 0
def test_standard_tier_produces_output(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator(quality_tier="standard")
result = gen.generate(simple_ontology)
assert len(gen.serialize(result)) > 0
def test_strict_tier_produces_output(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator(quality_tier="strict")
result = gen.generate(simple_ontology)
assert len(gen.serialize(result)) > 0
def test_strict_tier_contains_closed_constraint(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator(quality_tier="strict")
result = gen.generate(simple_ontology)
turtle = gen.serialize(result)
assert "sh:closed" in turtle
def test_basic_tier_does_not_contain_closed(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
gen = SHACLGenerator(quality_tier="basic")
result = gen.generate(simple_ontology)
turtle = gen.serialize(result)
assert "sh:closed" not in turtle
def test_three_tiers_produce_different_output(self, simple_ontology):
from semantica.ontology.ontology_generator import SHACLGenerator
basic_gen = SHACLGenerator(quality_tier="basic")
strict_gen = SHACLGenerator(quality_tier="strict")
basic = basic_gen.serialize(basic_gen.generate(simple_ontology))
strict = strict_gen.serialize(strict_gen.generate(simple_ontology))
assert basic != strict
class TestRDFExporterExportSHACL:
"""RDFExporter.export_shacl() writes SHACL strings to files."""
def test_export_shacl_writes_ttl_file(self, tmp_path):
from semantica.export.rdf_exporter import RDFExporter
exporter = RDFExporter()
shacl = "@prefix sh: <http://www.w3.org/ns/shacl#> .\n"
out = tmp_path / "shapes.ttl"
exporter.export_shacl(shacl, str(out))
assert out.exists()
assert out.read_text().strip().startswith("@prefix")
def test_export_shacl_invalid_extension_raises(self, tmp_path):
from semantica.export.rdf_exporter import RDFExporter
from semantica.utils.exceptions import ValidationError
exporter = RDFExporter()
out = tmp_path / "shapes.txt"
with pytest.raises((ValueError, ValidationError)):
exporter.export_shacl("@prefix sh: <…> .", str(out))
def test_export_shacl_jsonld_extension_accepted(self, tmp_path):
from semantica.export.rdf_exporter import RDFExporter
exporter = RDFExporter()
content = '{"@context": {}}'
out = tmp_path / "shapes.jsonld"
exporter.export_shacl(content, str(out))
assert out.exists()
# ===========================================================================
# PR #408 — OllamaProvider base_url fix (gap tests)
# ===========================================================================
class TestOllamaProviderBaseURLGap:
"""Additional gap tests for PR #408 OllamaProvider base_url fix."""
def test_custom_port_used_as_host(self):
"""Non-default port must flow through to the Client in every call."""
ollama_mock = MagicMock()
ollama_mock.Client = MagicMock(return_value=MagicMock())
with patch.dict("sys.modules", {"ollama": ollama_mock}):
from semantica.semantic_extract.providers import OllamaProvider
provider = OllamaProvider(
model_name="llama3",
base_url="http://192.168.1.10:11434",
)
# _init_client may be called during __init__ and/or lazily;
# every invocation must pass the correct host.
assert ollama_mock.Client.called
for call_args in ollama_mock.Client.call_args_list:
assert call_args == ((), {"host": "http://192.168.1.10:11434"}) or \
call_args.kwargs.get("host") == "http://192.168.1.10:11434"
def test_client_is_not_raw_module(self):
"""self.client must never be the raw ollama module."""
ollama_mock = MagicMock()
client_instance = MagicMock()
ollama_mock.Client = MagicMock(return_value=client_instance)
with patch.dict("sys.modules", {"ollama": ollama_mock}):
from semantica.semantic_extract.providers import OllamaProvider
provider = OllamaProvider(model_name="llama3")
provider._init_client()
assert provider.client is not ollama_mock
# ===========================================================================
# PR #371 — DatalogReasoner gap tests
# ===========================================================================
class TestDatalogReasonerGap:
"""Gap tests for DatalogReasoner beyond the existing 23 tests."""
@pytest.fixture
def reasoner(self):
from semantica.reasoning import DatalogReasoner
return DatalogReasoner()
def test_derive_all_idempotent(self, reasoner):
reasoner.add_fact("parent(alice, bob)")
reasoner.add_rule("grandparent(X, Z) :- parent(X, Y), parent(Y, Z).")
reasoner.add_fact("parent(bob, carol)")
first = reasoner.derive_all()
second = reasoner.derive_all()
# Second call must produce same results (idempotency)
assert set(first) == set(second)
def test_query_returns_list(self, reasoner):
reasoner.add_fact("color(sky, blue)")
result = reasoner.query("color(?X, ?Y)")
assert isinstance(result, list)
def test_query_no_match_returns_empty(self, reasoner):
result = reasoner.query("nonexistent(?X)")
assert result == []
def test_multi_hop_four_levels(self, reasoner):
reasoner.add_fact("parent(a, b)")
reasoner.add_fact("parent(b, c)")
reasoner.add_fact("parent(c, d)")
reasoner.add_fact("parent(d, e)")
# DatalogReasoner uses uppercase-letter variables (not ?-prefixed)
reasoner.add_rule("ancestor(X, Z) :- parent(X, Z).")
reasoner.add_rule("ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).")
results = reasoner.query("ancestor(a, ?Z)")
targets = {r["Z"] for r in results}
assert "e" in targets
def test_load_from_context_graph(self, reasoner):
from semantica.context import ContextGraph
graph = ContextGraph()
graph.add_node("alice", "Person")
graph.add_node("bob", "Person")
graph.add_edge("alice", "bob", "knows")
reasoner.load_from_graph(graph)
result = reasoner.query("knows(?X, ?Y)")
assert len(result) >= 1
def test_add_fact_dict_source_target_type(self, reasoner):
reasoner.add_fact({"source": "alice", "target": "bob", "type": "knows"})
result = reasoner.query("knows(?X, ?Y)")
assert any(r.get("X") == "alice" and r.get("Y") == "bob" for r in result)
def test_add_fact_subject_predicate_object_shape(self, reasoner):
reasoner.add_fact({"subject": "cat", "predicate": "isa", "object": "animal"})
result = reasoner.query("isa(?X, ?Y)")
assert len(result) >= 1
def test_duplicate_fact_not_duplicated(self, reasoner):
reasoner.add_fact("color(sky, blue)")
reasoner.add_fact("color(sky, blue)")
result = reasoner.query("color(?X, ?Y)")
assert len(result) == 1
def test_derive_all_returns_list(self, reasoner):
# Facts must use constants (lowercase); uppercase is treated as variable
reasoner.add_fact("category(x, alpha)")
result = reasoner.derive_all()
assert isinstance(result, list)
+320
View File
@@ -162,3 +162,323 @@ 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()
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_execute_query_forwards_graph_options(self, mock_blazegraph_store):
mock_backend_instance = MagicMock()
mock_blazegraph_store.return_value = mock_backend_instance
store = TripletStore(backend="blazegraph")
store.query_engine = MagicMock()
store.query_engine.execute_query.return_value = QueryEngine()
query = "SELECT ?s WHERE { ?s ?p ?o }"
graphs = ["http://example.org/graph/a", "http://example.org/graph/b"]
store.execute_query(query, graph="http://example.org/graph/default", graphs=graphs)
store.query_engine.execute_query.assert_called_once_with(
query,
store._store_backend,
graph="http://example.org/graph/default",
graphs=graphs,
supports_named_graphs=True,
)
@patch('semantica.triplet_store.blazegraph_store.BlazegraphStore')
def test_execute_query_respects_enable_named_graphs_flag(self, mock_blazegraph_store):
mock_backend_instance = MagicMock()
mock_blazegraph_store.return_value = mock_backend_instance
store = TripletStore(backend="blazegraph", enable_named_graphs=False)
store.query_engine = MagicMock()
store.query_engine.execute_query.return_value = QueryEngine()
query = "SELECT ?s WHERE { ?s ?p ?o }"
store.execute_query(query, graph="http://example.org/graph/default")
store.query_engine.execute_query.assert_called_once_with(
query,
store._store_backend,
graph="http://example.org/graph/default",
supports_named_graphs=False,
)
def test_query_engine_injects_from_before_where(self):
engine = QueryEngine(enable_optimization=False, enable_caching=False)
query = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }"
prepared = engine.prepare_query(query, graph="http://example.org/graph/default")
self.assertIn("FROM <http://example.org/graph/default>", prepared)
self.assertLess(
prepared.upper().find("FROM <HTTP://EXAMPLE.ORG/GRAPH/DEFAULT>"),
prepared.upper().find("WHERE"),
)
def test_query_engine_injects_multiple_named_graphs(self):
engine = QueryEngine(enable_optimization=False, enable_caching=False)
query = "SELECT ?s WHERE { GRAPH ?g { ?s ?p ?o } }"
graphs = ["http://example.org/graph/a", "http://example.org/graph/b"]
prepared = engine.prepare_query(query, graphs=graphs)
self.assertIn("FROM NAMED <http://example.org/graph/a>", prepared)
self.assertIn("FROM NAMED <http://example.org/graph/b>", prepared)
self.assertLess(
prepared.upper().find("FROM NAMED <HTTP://EXAMPLE.ORG/GRAPH/A>"),
prepared.upper().find("WHERE"),
)
def test_query_engine_graph_isolation_behavior(self):
engine = QueryEngine(enable_optimization=False, enable_caching=False)
mock_backend = MagicMock()
def _side_effect(query, **kwargs):
if "FROM <http://example.org/graph/a>" in query:
return {
"bindings": [{"s": {"value": "http://entity/A"}}],
"variables": ["s"],
"metadata": {},
}
if "FROM <http://example.org/graph/b>" in query:
return {
"bindings": [{"s": {"value": "http://entity/B"}}],
"variables": ["s"],
"metadata": {},
}
return {
"bindings": [
{"s": {"value": "http://entity/A"}},
{"s": {"value": "http://entity/B"}},
],
"variables": ["s"],
"metadata": {},
}
mock_backend.execute_sparql.side_effect = _side_effect
base_query = "SELECT ?s WHERE { ?s ?p ?o }"
graph_a_result = engine.execute_query(base_query, mock_backend, graph="http://example.org/graph/a")
graph_b_result = engine.execute_query(base_query, mock_backend, graph="http://example.org/graph/b")
default_result = engine.execute_query(base_query, mock_backend)
self.assertNotEqual(graph_a_result.bindings, graph_b_result.bindings)
self.assertEqual(len(default_result.bindings), 2)
def test_query_engine_avoids_duplicate_dataset_clauses_for_same_graph(self):
engine = QueryEngine(enable_optimization=False, enable_caching=False)
query = "SELECT ?s WHERE { GRAPH ?g { ?s ?p ?o } }"
prepared = engine.prepare_query(
query,
graph="http://example.org/graph/a",
graphs=["http://example.org/graph/a", "http://example.org/graph/b"],
)
self.assertEqual(prepared.count("FROM <http://example.org/graph/a>"), 1)
self.assertEqual(prepared.count("FROM NAMED <http://example.org/graph/a>"), 0)
self.assertIn("FROM NAMED <http://example.org/graph/b>", prepared)
def test_query_engine_uses_default_graph_uri_alias(self):
engine = QueryEngine(
enable_optimization=False,
enable_caching=False,
default_graph_uri="http://example.org/graph/default",
)
query = "SELECT ?s WHERE { ?s ?p ?o }"
prepared = engine.prepare_query(query)
self.assertIn("FROM <http://example.org/graph/default>", prepared)
def test_query_engine_fallback_when_named_graphs_unsupported(self):
engine = QueryEngine(enable_optimization=False, enable_caching=False)
query = "SELECT ?s WHERE { ?s ?p ?o }"
prepared = engine.prepare_query(
query,
graph="http://example.org/graph/default",
supports_named_graphs=False,
)
self.assertEqual(prepared, query)
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(), [])