mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Merge branch 'main' into docs
This commit is contained in:
@@ -10,6 +10,9 @@ on:
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
performance-test:
|
||||
name: Benchmark Runner (Ubuntu/Python 3.12)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+62
-1097
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.0–1.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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for the Semantica Knowledge Explorer."""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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(), [])
|
||||
|
||||
Reference in New Issue
Block a user