Compare commits

..
13 changed files with 203 additions and 1164 deletions
+4 -4
View File
@@ -12,13 +12,13 @@ icon: "link"
pip install "semantica[langchain]"
```
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports. Every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
Requires `langchain-core >= 0.3`. If langchain-core is not installed, the integration still imports — every class carries the full Semantica API and degrades gracefully (`build()` returns `None`; branch on `LANGCHAIN_AVAILABLE`).
## Components at a Glance
- **SemanticaRetriever** (`BaseRetriever`): hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
- **SemanticaVectorStore** (`VectorStore`): `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
- **SemanticaKGTool** / **SemanticaDecisionTool** (`BaseTool` subclasses): `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
- **SemanticaRetriever** `BaseRetriever`: hybrid-search seeds retrieval, then graph edges are walked `hops` steps (default 2) for GraphRAG-style results.
- **SemanticaVectorStore** `VectorStore`: `add_texts` / `similarity_search` / `similarity_search_with_score` / `from_texts` over `HybridSearch`.
- **SemanticaKGTool** / **SemanticaDecisionTool** `BaseTool` subclasses: `semantica_query_graph` and `semantica_query_decisions` for LangGraph / tool-calling agents.
## Component Details
+121 -162
View File
@@ -28,9 +28,7 @@ Semantica is organized into **27 modules** across six logical layers. Each modul
### Ingest
Loads data from files, web, databases, and streams. Each ingestor returns its own
result type (`FileIngestor``FileObject`, `WebIngestor``WebContent`, …);
document-oriented ones expose a `.text` payload and `.metadata`.
Loads data from files, web, databases, and streams into a unified `SourceDocument` format.
```python
from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLIngestor, DatabricksIngestor
@@ -39,7 +37,7 @@ from semantica.ingest import FileIngestor, WebIngestor, ParquetIngestor, XMLInge
ingestor = FileIngestor()
documents = ingestor.ingest_directory("data/")
# Web page: returns a WebContent with .text, .title, .links, .metadata
# Web crawl
web_ingestor = WebIngestor()
page = web_ingestor.ingest_url("https://example.com")
@@ -69,13 +67,13 @@ Extracts structured text and layout metadata from raw documents.
```python
from semantica.parse import DocumentParser, DoclingParser
# Standard parser: all common formats. parse() takes a path, returns a dict
# Standard parser: all common formats
parser = DocumentParser()
parsed = parser.parse("document.pdf") # {"full_text": ..., "metadata": ..., ...}
parsed = parser.parse_document("document.pdf")
# Advanced parser (pip install semantica[parse-docling]): tables, OCR, layout
parser = DoclingParser(export_format="markdown", enable_ocr=True)
parsed = parser.parse("data/annual_report.pdf") # dict with full_text, tables, pages
# Advanced parser: multi-column PDFs, merged-cell tables, OCR
parser = DoclingParser(extract_tables=True, extract_images=True, output_format="markdown")
parsed = parser.parse("data/annual_report.pdf")
```
**Available parsers:** `DocumentParser`, `DoclingParser`, `CodeParser`, `CSVParser`, `DocxParser`, `EmailParser`, `ExcelParser`, `HTMLParser`, `ImageParser`, `JSONParser`, `MCPParser`, `MediaParser`, `PDFParser`, `PPTXParser`, `StructuredDataParser`, `WebParser`, `XMLParser`
@@ -87,12 +85,11 @@ Chunks text for embedding and RAG pipelines with awareness of semantic boundarie
```python
from semantica.split import TextSplitter
# chunk_size / chunk_overlap are constructor arguments
splitter = TextSplitter(method="semantic_transformer", chunk_size=1000, chunk_overlap=200)
chunks = splitter.split(text)
splitter = TextSplitter(method="semantic_transformer")
chunks = splitter.split(text, chunk_size=1000, chunk_overlap=200)
```
**Chunking methods:** `recursive`, `token`, `sentence`, `paragraph`, `semantic_transformer`, `entity_aware`, `relation_aware`, `graph_based`, `ontology_aware`, `hierarchical`, `community_detection`, `centrality_based`, `llm`
**Chunking strategies:** `recursive`, `semantic_transformer`, `entity_aware`, `relation_aware`, `sliding_window`, `structural`
### Normalize
@@ -118,18 +115,17 @@ Named entity recognition, relation extraction, and triplet generation.
```python
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
# LLM method: provider + llm_model select the backend; the API key comes from the env
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract("Apple Inc. was founded by Steve Jobs.") # list[Entity]
ner = NERExtractor(method="llm", llm_provider=llm)
entities = ner.extract("Apple Inc. was founded by Steve Jobs.")
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
relationships = rel.extract(text, entities=entities) # list[Relation]
rel = RelationExtractor(method="llm", llm_provider=llm)
relationships = rel.extract(text, entities=entities)
trip = TripletExtractor(method="pattern")
triplets = trip.extract(text) # list[Triplet]
trip = TripletExtractor(method="llm", llm_provider=llm)
triplets = trip.extract(text)
```
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local spaCy model), `"llm"` (any of the 9 supported providers)
**Extraction methods:** `"pattern"` (no API key), `"ml"` (local model), `"llm"` (any of the 8 supported providers)
**Additional extractors:** `CoreferenceResolver`, `EventDetector`, `SemanticAnalyzer`, `SemanticNetworkExtractor`
@@ -141,17 +137,17 @@ Graph construction, graph algorithms, temporal model, and distance intelligence.
from semantica.kg import GraphBuilder, GraphAnalyzer, TemporalGraphQuery, SimilarityCalculator
from datetime import datetime
# Build: build() takes a {"entities": ..., "relationships": ...} dict
# Build
builder = GraphBuilder(merge_entities=True)
kg = builder.build({"entities": entities, "relationships": relationships})
kg = builder.build(entities=entities, relationships=relationships)
# Temporal graphs (v0.4.0)
query_engine = TemporalGraphQuery(enable_temporal_reasoning=True)
snapshot = query_engine.query_at_time(kg, query="", at_time=datetime(2021, 6, 15))
# Semantic similarity (v0.5.0): operates on embedding vectors
calc = SimilarityCalculator(method="cosine")
score = calc.cosine_similarity(vec_a, vec_b)
# Semantic similarity (v0.5.0)
calc = SimilarityCalculator()
scores = calc.calculate_similarity(entity_a, entity_b)
```
**Graph algorithms available:** centrality calculation, community detection, connectivity analysis, entity resolution, link prediction, path finding, similarity calculation
@@ -179,23 +175,19 @@ Derives new facts from existing knowledge using multiple inference strategies.
```python
from semantica.reasoning import Reasoner, DatalogReasoner
# Forward chaining: facts and rules as predicate(args) / IF-THEN strings
# Rule-based reasoning
engine = Reasoner()
engine.add_fact("Manager(Alice)")
engine.add_rule("IF Manager(?x) THEN HasAuthority(?x)")
results = engine.forward_chain() # list[InferenceResult] with .conclusion, .rule_used
engine.apply_transitivity("located_in")
engine.apply_symmetry("knows")
result = engine.infer()
# Datalog: recursive Horn clause rules (v0.4.0)
datalog = DatalogReasoner()
datalog.add_fact("parent(tom, bob)")
datalog.add_fact("parent(bob, ann)")
datalog.add_rule("ancestor(X, Y) :- parent(X, Y).")
datalog = DatalogEngine()
datalog.add_rule("ancestor(X, Z) :- parent(X, Y), ancestor(Y, Z).")
datalog.derive_all()
results = datalog.query("ancestor(tom, ?Z)") # [{"Z": "bob"}, {"Z": "ann"}], order not guaranteed
results = datalog.query("ancestor(alice, ?)")
```
**Engines:** `Reasoner` (forward/backward chaining), `ReteEngine`, `SPARQLReasoner`, `DatalogReasoner`, `TemporalReasoningEngine`, `GraphReasoner` (LLM)
**Engines:** forward chaining, Rete network, deductive, abductive, SPARQL, Datalog: all produce explainable inference paths
## Storage
@@ -207,9 +199,9 @@ Generates and manages vector embeddings for semantic similarity.
```python
from semantica.embeddings import EmbeddingGenerator
generator = EmbeddingGenerator()
embeddings = generator.generate_embeddings(["text1", "text2"]) # np.ndarray
similarity = generator.compare_embeddings(embeddings[0], embeddings[1])
generator = EmbeddingGenerator(model="sentence-transformers")
embeddings = generator.generate(["text1", "text2"])
similarity = generator.similarity(embeddings[0], embeddings[1])
```
**Supported models:** Sentence-Transformers, FastEmbed, OpenAI, BGE
@@ -223,18 +215,12 @@ Multi-backend vector database with hybrid search support.
```python
from semantica.vector_store import VectorStore
store = VectorStore(backend="faiss", dimension=768)
# Raw vectors
ids = store.store_vectors(embeddings) # returns generated ids
hits = store.search_vectors(query_vector, k=10)
# Or store text and let the store embed it
store.add_documents(["Apple was founded in 1976.", "Google was founded in 1998."])
results = store.search("tech company founding dates", limit=10)
store = VectorStore(backend="faiss", dimension=768)
store.add_vectors(embeddings, ids)
results = store.search(query_vector, top_k=10)
```
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, SQLite, in-memory
**Backends:** FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
**Search modes:** semantic top-k, hybrid (vector + keyword), metadata-filtered
@@ -246,8 +232,8 @@ Connects to graph databases for persistent, query-able storage.
from semantica.graph_store import GraphStore
store = GraphStore(backend="neo4j")
store.add_nodes([{"id": "acme", "type": "Organization", "properties": {"name": "Acme"}}])
store.add_edges([{"source": "alice", "target": "acme", "type": "works_for"}])
store.add_nodes(entities)
store.add_edges(relationships)
results = store.query("MATCH (n)-[r]->(m) RETURN n, r, m")
```
@@ -260,9 +246,9 @@ RDF triple-based storage with SPARQL query support.
```python
from semantica.triplet_store import TripletStore
store = TripletStore(backend="oxigraph")
store.add_triplets(triplets) # list of Triplet objects (or add_triplet for one)
results = store.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
store = TripletStore(backend="blazegraph")
store.add_triplets(subject, predicate, obj)
results = store.sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
```
**Backends:** Oxigraph (embedded), Blazegraph, Apache Jena, RDF4J
@@ -275,18 +261,15 @@ results = store.execute_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
Detects, scores, and merges duplicate entities across sources.
```python
from semantica.deduplication import DuplicateDetector, EntityMerger
from semantica.deduplication import EntityResolver
detector = DuplicateDetector(similarity_threshold=0.85)
candidates = detector.detect_duplicates(entities)
merger = EntityMerger()
operations = merger.merge_duplicates(entities, strategy="keep_most_complete")
resolver = EntityResolver()
merged = resolver.resolve(entities, strategy="semantic_v2")
```
**v2 candidate-generation modes** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
**v2 strategies** (`blocking_v2`, `hybrid_v2`, `semantic_v2`) are up to 7x faster than v1.
**Components:** `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager`
**Components:** `EntityResolver`, `DuplicateDetector`, `EntityMerger`, `SimilarityCalculator`, `ClusterBuilder`
**`DuplicateDetector` options:** `max_results`, `top_k_per_entity`, `min_similarity`, `sort_by`
@@ -295,13 +278,14 @@ operations = merger.merge_duplicates(entities, strategy="keep_most_complete")
Detects and resolves fact conflicts across overlapping knowledge sources.
```python
from semantica.conflicts import ConflictDetector, ConflictResolver
from semantica.conflicts import ConflictDetector
conflicts = ConflictDetector().detect_conflicts(entities) # list of entity dicts
resolved = ConflictResolver().resolve_conflicts(conflicts, strategy="most_recent")
detector = ConflictDetector()
conflicts = detector.detect_conflicts(kg)
resolved = detector.resolve(conflicts, strategy="most_recent")
```
**Detection types:** value conflicts, type conflicts, relationship conflicts, temporal conflicts, logical conflicts
**Detection types:** value conflicts, type conflicts, temporal conflicts, logical conflicts
**Resolution strategies:** prefer most recent, prefer most reliable source, majority vote, flag for manual review
@@ -314,7 +298,6 @@ Agent context graphs, decision tracking, causal chains, and precedent search.
```python
from semantica.context import AgentContext, ContextGraph
from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
@@ -345,7 +328,7 @@ W3C PROV-O compliant lineage tracking across all modules.
from semantica.provenance import ProvenanceManager
manager = ProvenanceManager()
manager.track_entity("entity_1", source="document.pdf", metadata={"type": "person"})
manager.track_entity("entity_1", "document.pdf", "person")
lineage = manager.get_lineage("entity_1")
```
@@ -381,8 +364,8 @@ RDFExporter().export(graph, file_path="graph.ttl", format="turtle")
# Analytics
ParquetExporter().export(graph, file_path="output/graph.parquet")
# ArangoDB: writes AQL INSERT statements to the given path
ArangoAQLExporter().export(graph, file_path="graph.aql")
# ArangoDB
aql = ArangoAQLExporter().export(graph)
```
**Export formats:** RDF (Turtle, JSON-LD, N-Triples, XML), Parquet, ArangoDB AQL, CSV, OWL, Arrow, LPG, YAML, distance matrices
@@ -407,24 +390,16 @@ viz.visualize_network(graph, output="html", file_path="graph.html")
Pipeline DSL with parallel workers, retry policies, and failure handling.
```python
from semantica.pipeline import PipelineBuilder, ExecutionEngine
from semantica.ingest import FileIngestor
from semantica.semantic_extract import NERExtractor
from semantica.pipeline import Pipeline
builder = PipelineBuilder()
# Each step type dispatches to a handler you register (or supply explicitly)
builder.register_step_handler("ingest", lambda data, **c: FileIngestor().ingest(c["source"]))
builder.register_step_handler("extract", lambda docs, **c: NERExtractor(method="pattern").extract(docs[0].text))
builder.add_step("ingest", step_type="ingest", source="data/")
builder.add_step("extract", step_type="extract")
pipeline = builder.connect_steps("ingest", "extract").build(name="docs_to_entities")
result = ExecutionEngine().execute_pipeline(pipeline)
pipeline = Pipeline()
pipeline.add_step("ingest", FileIngestor())
pipeline.add_step("extract", NERExtractor())
pipeline.add_step("build", GraphBuilder())
result = pipeline.run("data/")
```
**Components:** `PipelineBuilder`, `Pipeline`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
**Components:** `Pipeline`, `PipelineBuilder`, `ExecutionEngine`, `FailureHandler`, `PipelineValidator`, `ParallelismManager`, `ResourceScheduler`
### Explorer
@@ -453,7 +428,7 @@ llm = OpenAI(model="gpt-4o", api_key=os.getenv("OPENAI_API_KEY"))
llm = LiteLLM(model="anthropic/claude-opus-4-7", api_key=os.getenv("ANTHROPIC_API_KEY"))
```
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, HuggingFace, plus LiteLLM (100+ models via one interface)
**Supported providers:** OpenAI, Anthropic, Google Gemini, Groq, Ollama, DeepSeek, Novita AI, LiteLLM (20+ models via one interface)
### MCP Server
@@ -470,43 +445,44 @@ python -m semantica.mcp_server
Bootstrap knowledge graphs from verified structured sources: fixed-point reference data, controlled vocabularies, and domain anchors.
```python
from semantica.seed import SeedDataManager
from semantica.seed import SeedManager
seed = SeedDataManager()
seed = SeedManager()
seed.populate(kg, dataset="companies", count=100)
# Load trusted reference data from CSV / JSON / a database / an API
seed_data = seed.load_from_csv("seed_data/industries.csv", entity_type="Industry")
# Merge seed data with extraction output (seed values win on conflict by default)
combined = seed.integrate_with_extracted(
{"entities": seed_data, "relationships": []},
{"entities": extracted_entities, "relationships": extracted_relationships},
merge_strategy="seed_first",
)
# Load domain seeds from file or built-in datasets
seed.load_from_file("seed_data/industries.json")
seed.inject(kg) # merges seed nodes without duplicating existing entities
```
**Use cases:** anchoring extraction with known entities, pre-populating ontology classes, deterministic test graph generation.
### Evals
Scores decision-intelligence outputs (decision records, audit trails, reasoning
text) with a registry of deterministic and model-backed evaluators plus a small
run harness.
Evaluation framework for measuring KG quality, extraction accuracy, and pipeline performance.
```python
from semantica.evals import evaluate, list_evaluators
from semantica.evals import KGEvaluator, ExtractionEvaluator, PipelineEvaluator, RegressionTracker
list_evaluators()
# ['decision_scores', 'exact_match', 'keyword_check', 'length_range',
# 'levenshtein', 'llm_as_judge', 'numeric_range', 'regex_match', 'rouge',
# 'temporal_range']
# KG quality
report = KGEvaluator().evaluate(kg, ontology=ontology)
print(f"Completeness: {report.completeness:.2%} Consistency: {report.consistency:.2%}")
cases = [("apple", "aple"), ("night", "nacht")]
summary = evaluate(cases, evaluators=["levenshtein"])
print(summary.total, summary.passed, summary.pass_rate)
# Extraction accuracy
report = ExtractionEvaluator().evaluate_ner(predictions=extracted, gold_standard=annotated)
print(f"Precision: {report.precision:.3f} Recall: {report.recall:.3f} F1: {report.f1:.3f}")
# Pipeline throughput and latency
metrics = PipelineEvaluator().benchmark(pipeline, data="data/", bench_runs=5)
print(f"Throughput: {metrics.docs_per_second:.1f} docs/sec")
# Regression tracking across runs
tracker = RegressionTracker(db_path="eval_history.db")
run_id = tracker.record_run(pipeline_version="v1.2.0", metrics=metrics)
diff = tracker.compare(run_id, baseline_run_id="run_abc123")
```
**Public API:** `evaluate(cases, evaluators, config=None)`, `list_evaluators()`, `get_evaluator(name)`, and the `EvalMetric` / `CaseResult` / `EvalSummary` result types. See the [Evals reference](/reference/evals).
**Components:** `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker`
### Core
@@ -515,20 +491,20 @@ Base classes, shared data models, and the plugin registry used across all module
```python
from semantica.core import Semantica, PluginRegistry, ConfigManager
# ConfigManager loads a Config; Config.get() does dotted lookups
config = ConfigManager().load_from_file("config.yaml")
batch = config.get("processing.batch_size", default=32)
# Top-level orchestrator: pass the Config object (or a dict), not a path
sem = Semantica(config=config)
# Top-level orchestrator
sem = Semantica(config_path="config.yaml")
sem.initialize()
# Plugin registry: register custom components under a name
# Plugin registry: register custom components
registry = PluginRegistry()
registry.register_plugin("my_ingestor", MyCustomIngestor, version="1.0.0")
registry.register("my_ingestor", MyCustomIngestor)
# Config management
config = ConfigManager(config_path="config.yaml")
batch = config.get("processing.batch_size", default=32)
```
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `Config`, `LifecycleManager`, `HealthStatus`, `MethodRegistry`
**Components:** `Semantica`, `PluginRegistry`, `ConfigManager`, `LifecycleManager`, `HealthMonitor`, `Config`
### Utils
@@ -556,13 +532,11 @@ from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.kg import GraphBuilder
sources = FileIngestor().ingest("data/")
text = DocumentParser().parse(sources[0].path)["full_text"]
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
rel = RelationExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract(text)
relationships = rel.extract(text, entities=entities)
parsed = DocumentParser().parse(sources[0])
entities = NERExtractor(method="llm", llm_provider=llm).extract(parsed)
relationships = RelationExtractor(method="llm", llm_provider=llm).extract(parsed, entities=entities)
graph = GraphBuilder(merge_entities=True).build(
{"entities": entities, "relationships": relationships}
entities=entities, relationships=relationships
)
```
@@ -581,20 +555,16 @@ from semantica.vector_store import VectorStore
context = AgentContext(
vector_store=VectorStore(backend="faiss", dimension=768),
knowledge_graph=ContextGraph(advanced_analytics=True),
graph_expansion=True,
)
context.load_graph("company_kg.json")
# store() extracts entities and populates the graph + vector index
context.store([{"content": "Steve Wozniak co-founded Apple with Steve Jobs."}])
# retrieve() blends vector similarity with multi-hop graph traversal
results = context.retrieve(
result = context.query(
"What companies did Apple alumni found?",
use_graph=True,
expand_graph=True,
mode="graphrag",
reasoning=True,
)
for r in results:
print(f"[{r['score']:.3f}] {r['content']} (source: {r['source']})")
for claim in result.claims:
print(f"{claim.text} {claim.source_node}")
```
**Best for:** question-answering systems, RAG with source attribution, research assistants
@@ -636,22 +606,18 @@ precedents = context.find_precedents("model selection", limit=5)
```python
from semantica.ingest import FileIngestor
from semantica.parse import DocumentParser
from semantica.semantic_extract import NERExtractor
from semantica.kg import GraphBuilder
from semantica.provenance import ProvenanceManager
from semantica.export import RDFExporter
sources = FileIngestor().ingest("records/")
ner = NERExtractor(method="llm", provider="groq", llm_model="llama-3.3-70b-versatile")
entities = ner.extract(DocumentParser().parse(sources[0].path)["full_text"])
graph = GraphBuilder(merge_entities=True).build({"entities": entities, "relationships": []})
entities = NERExtractor(method="llm", llm_provider=llm).extract(sources)
graph = GraphBuilder(merge_entities=True).build(entities=entities, relationships=[])
prov = ProvenanceManager()
prov.track_entity("entity_id", source="records/filing.pdf", metadata={"extractor": "llm"})
lineage = prov.get_lineage("entity_id")
lineage = prov.get_entity_lineage("entity_id")
RDFExporter().export(graph, file_path="audit.ttl", format="turtle")
RDFExporter(include_provenance=True).export(graph, file_path="audit.ttl", format="turtle")
```
**Best for:** HIPAA, SOX, GDPR, FDA 21 CFR Part 11 deployments
@@ -666,25 +632,18 @@ RDFExporter().export(graph, file_path="audit.ttl", format="turtle")
from semantica.ingest import WebIngestor
from semantica.normalize import TextNormalizer
from semantica.semantic_extract import NERExtractor, RelationExtractor
from semantica.graph_store import GraphStore
from semantica.kg import GraphBuilder
from semantica.graph_store import Neo4jStore
ingestor = WebIngestor()
pages = WebIngestor(max_depth=2).ingest("https://example.com")
normalizer = TextNormalizer()
ner = NERExtractor(method="pattern")
rel = RelationExtractor(method="pattern")
store = Neo4jStore(uri="bolt://localhost:7687", user="neo4j", password="password")
# The generic GraphStore wrapper exposes the add_nodes/add_edges interface
# GraphBuilder persists through; a raw Neo4jStore does not
store = GraphStore(backend="neo4j", uri="bolt://localhost:7687", user="neo4j", password="password")
builder = GraphBuilder(merge_entities=True, graph_store=store)
for url in ["https://example.com/a", "https://example.com/b"]:
page = ingestor.ingest_url(url) # WebContent, has .text
for page in pages:
text = normalizer.normalize_text(page.text)
entities = ner.extract(text)
relationships = rel.extract(text, entities=entities)
builder.build({"entities": entities, "relationships": relationships})
entities = NERExtractor().extract(text)
relationships = RelationExtractor().extract(text, entities=entities)
store.add_nodes(entities)
store.add_edges(relationships)
```
**Best for:** competitive intelligence, news monitoring, research aggregation
@@ -733,8 +692,8 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
| [vector_store](/reference/vector_store) | Vector database | `VectorStore` |
| [graph_store](/reference/graph_store) | Graph database | `GraphStore` |
| [triplet_store](/reference/triplet_store) | RDF triple store | `TripletStore` |
| [deduplication](/reference/deduplication) | Entity resolution | `DuplicateDetector`, `EntityMerger`, `ClusterBuilder`, `MergeStrategyManager` |
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector`, `ConflictResolver`, `SourceTracker` |
| [deduplication](/reference/deduplication) | Entity resolution | `EntityResolver`, `DuplicateDetector`, `ClusterBuilder`, `MergeStrategyManager` |
| [conflicts](/reference/conflicts) | Conflict resolution | `ConflictDetector` |
| [context](/reference/context) | Agent context & decisions | `AgentContext`, `ContextGraph` |
| [provenance](/reference/provenance) | W3C PROV-O lineage | `ProvenanceManager` |
| [change_management](/reference/change_management) | Version control | `TemporalVersionManager` |
@@ -744,8 +703,8 @@ versioner.create_snapshot(kg, "2024-Q1", author="user@example.com", description=
| [explorer](/reference/explorer) | Knowledge Explorer UI | `semantica-explorer --graph <file>` |
| [llms](/reference/llms) | LLM providers | `Groq`, `OpenAI`, `create_provider` |
| [mcp_server](/reference/mcp_server) | MCP stdio server | `python -m semantica.mcp_server` |
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedDataManager` |
| [evals](/reference/evals) | Decision-intelligence evaluation | `evaluate`, `list_evaluators`, `EvalSummary` |
| [seed](/reference/seed) | KG bootstrapping from structured sources | `SeedManager` |
| [evals](/reference/evals) | Quality evaluation | `KGEvaluator`, `ExtractionEvaluator`, `PipelineEvaluator`, `RegressionTracker` |
| [core](/reference/core) | Base classes & registry | `Semantica`, `ConfigManager`, `PluginRegistry`, `LifecycleManager` |
| [utils](/reference/utils) | Shared utilities | `helpers`, `validators` |
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts tests/ontologyEditorModel.test.ts",
"test:graph-workspace": "node --import tsx --test tests/markdownContentViewer.test.ts tests/graphSceneState.display.test.ts tests/temporalLifecycle.test.ts tests/deterministicExplorerRendering.test.ts tests/smallGraphLayout.test.ts tests/realtimeGraphAttributes.test.ts",
"test:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
+1 -13
View File
@@ -93,18 +93,6 @@ const navItems: NavItem[] = [
{ id: 'ontology-hub', label: 'Ontology Hub', hint: 'Schema governance, registry, and vocabulary management', icon: GitMerge },
];
function readInitialWorkspace(): WorkspaceId {
try {
const params = new URLSearchParams(window.location.search);
if (params.has("ontologyTab") || params.has("ontologyEntity")) {
return "ontology-hub";
}
} catch {
// Default to the welcome screen when URL state is unavailable.
}
return "welcome";
}
const shellStyles = `
:root {
--app-bg: #07111f;
@@ -1785,7 +1773,7 @@ function WelcomeScreen({
}
export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>(readInitialWorkspace);
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
@@ -8,10 +8,8 @@ import {
useNodesState,
useEdgesState,
MarkerType,
Handle,
Position,
} from "@xyflow/react";
import type { Connection, Edge, Node, ReactFlowInstance } from "@xyflow/react";
import type { Connection, Edge, Node } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Plus,
@@ -24,20 +22,10 @@ import {
Pencil,
Trash2,
} from "lucide-react";
import { loadOntologyEntityOwner, loadOntologyGraph } from "./api";
import type { OntologyGraphEdge, OntologyGraphNode } from "./api";
import {
classifyNodeType,
inferOntologyUri,
isEditableEntityType,
ONTOLOGY_MINIMAP_THEME,
} from "./ontologyEditorModel";
import type { EditorEntityType, RegistryEntry } from "./ontologyEditorModel";
type OntologyNodeData = {
label?: string;
type?: string;
entityType?: EditorEntityType;
};
type OntologyNode = Node<OntologyNodeData>;
@@ -46,57 +34,12 @@ type OntologyEdge = Edge<Record<string, unknown>>;
const nodeTypes = {
classNode: ({ data }: { data: OntologyNodeData }) => (
<div style={classNodeStyle}>
<Handle type="target" position={Position.Left} style={handleStyle} />
<div style={classNodeHeader}>{data.label}</div>
<div style={classNodeSub}>{data.type}</div>
<Handle type="source" position={Position.Right} style={handleStyle} />
</div>
),
};
const handleStyle: React.CSSProperties = {
width: 8,
height: 8,
border: "1px solid rgba(235, 243, 255, 0.8)",
background: "#4aa3ff",
};
const ontologyFlowThemeCss = `
.ontology-editor-flow .react-flow__controls {
overflow: hidden;
border: 1px solid rgba(127, 208, 255, 0.2);
border-radius: 9px;
background: rgba(6, 13, 26, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
}
.ontology-editor-flow .react-flow__controls-button {
width: 30px;
height: 30px;
background: transparent;
border-bottom-color: rgba(127, 208, 255, 0.14);
color: #8fa8c6;
transition: color 140ms ease, background 140ms ease;
}
.ontology-editor-flow .react-flow__controls-button:hover {
background: rgba(74, 163, 255, 0.14);
color: #ebf3ff;
}
.ontology-editor-flow .react-flow__controls-button:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #7fd0ff;
outline-offset: -2px;
}
.ontology-editor-flow .react-flow__controls-button:disabled {
background: rgba(3, 9, 18, 0.32);
color: #40566f;
}
`;
const classNodeStyle: React.CSSProperties = {
padding: "12px 16px",
borderRadius: "8px",
@@ -136,95 +79,17 @@ interface DraftDiff {
annotation_changes: Record<string, Record<string, any>>;
}
function requestedEntityUri(): string {
try {
return new URLSearchParams(window.location.search).get("ontologyEntity") || "";
} catch {
return "";
}
}
function nodeLabel(node: OntologyGraphNode): string {
const explicit = String(node.content || node.properties?.["rdfs:label"] || "").trim();
if (explicit && explicit !== node.id) {
return explicit;
}
const trimmed = node.id.replace(/[/#]+$/, "");
return trimmed.split("#").pop() || trimmed.split("/").pop() || node.id;
}
function classifyEditorNode(node: OntologyGraphNode): OntologyNodeData["entityType"] {
return classifyNodeType(node.type);
}
function layoutEditorNodes(inputNodes: OntologyNode[]): OntologyNode[] {
const properties = inputNodes.filter((node) => node.data.entityType === "property");
const targets = inputNodes.filter((node) => (
node.data.entityType === "class" || node.data.entityType === "external"
));
const context = inputNodes.filter((node) => (
node.data.entityType !== "property"
&& node.data.entityType !== "class"
&& node.data.entityType !== "external"
));
const height = Math.max(360, Math.max(properties.length, targets.length) * 180);
const positions = new Map<string, { x: number; y: number }>();
properties.forEach((node, index) => {
positions.set(node.id, { x: 0, y: ((index + 1) * height) / (properties.length + 1) });
});
targets.forEach((node, index) => {
positions.set(node.id, { x: 600, y: ((index + 1) * height) / (targets.length + 1) });
});
context.forEach((node, index) => {
positions.set(node.id, { x: 300 + index * 220, y: height + 120 });
});
return inputNodes.map((node) => ({
...node,
position: positions.get(node.id) || node.position,
}));
}
function buildEditorElements(apiNodes: OntologyGraphNode[], apiEdges: OntologyGraphEdge[]) {
const sortedNodes = [...apiNodes].sort((left, right) => {
const typeDelta = left.type.localeCompare(right.type);
return typeDelta || left.id.localeCompare(right.id);
});
const nodes = layoutEditorNodes(sortedNodes.map((node) => ({
id: node.id,
type: "classNode",
position: { x: 0, y: 0 },
data: {
label: nodeLabel(node),
type: node.type,
entityType: classifyEditorNode(node),
},
})));
const edges: OntologyEdge[] = apiEdges.map((edge, index) => ({
id: edge.id || `${edge.source}:${edge.type}:${edge.target}:${index}`,
source: edge.source,
target: edge.target,
label: edge.type,
type: "default",
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: "rgba(127, 208, 255, 0.72)", strokeWidth: 1.5 },
labelStyle: { fill: "#c8dcf5", fontSize: 11, fontWeight: 600 },
labelBgStyle: { fill: "#07111f", fillOpacity: 0.9 },
}));
return { nodes, edges };
interface RegistryEntry {
uri: string;
name: string;
}
export function OntologyEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState<OntologyNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<OntologyEdge>([]);
const [selectedElement, setSelectedElement] = useState<OntologyNode | OntologyEdge | null>(null);
const hasDetailPanel = selectedElement !== null;
const [registry, setRegistry] = useState<RegistryEntry[]>([]);
const [ontologyUri, setOntologyUri] = useState<string>("");
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<OntologyNode, OntologyEdge> | null>(null);
const [isLoadingGraph, setIsLoadingGraph] = useState(false);
const [graphError, setGraphError] = useState("");
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
added_classes: [],
removed_classes: [],
@@ -243,18 +108,12 @@ export function OntologyEditor() {
useEffect(() => {
let cancelled = false;
const requested = requestedEntityUri();
Promise.all([
fetch("/api/ontology/registry").then((response) => (response.ok ? response.json() : [])),
requested
? loadOntologyEntityOwner(requested).catch(() => undefined)
: Promise.resolve(undefined),
])
.then(([entries, explicitOwner]: [RegistryEntry[], string | undefined]) => {
fetch("/api/ontology/registry")
.then((response) => (response.ok ? response.json() : []))
.then((entries: RegistryEntry[]) => {
if (cancelled) return;
setRegistry(entries);
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
setOntologyUri((current) => current || entries[0]?.uri || "");
})
.catch((error) => {
console.error("Failed to load ontology registry:", error);
@@ -264,47 +123,6 @@ export function OntologyEditor() {
};
}, []);
useEffect(() => {
if (!ontologyUri) {
setNodes([]);
setEdges([]);
setSelectedElement(null);
return;
}
const controller = new AbortController();
setIsLoadingGraph(true);
setGraphError("");
loadOntologyGraph(ontologyUri, controller.signal)
.then((payload) => {
const elements = buildEditorElements(payload.nodes, payload.edges);
setNodes(elements.nodes);
setEdges(elements.edges);
const requested = requestedEntityUri();
setSelectedElement(elements.nodes.find((node) => node.id === requested) || null);
})
.catch((error) => {
if (controller.signal.aborted) return;
setNodes([]);
setEdges([]);
setSelectedElement(null);
setGraphError(error instanceof Error ? error.message : "Failed to load ontology graph");
})
.finally(() => {
if (!controller.signal.aborted) setIsLoadingGraph(false);
});
return () => controller.abort();
}, [ontologyUri, setEdges, setNodes]);
useEffect(() => {
if (!flowInstance || nodes.length === 0) return;
const frame = window.requestAnimationFrame(() => {
void flowInstance.fitView({ padding: 0.22, duration: 320, maxZoom: 1.25 });
});
return () => window.cancelAnimationFrame(frame);
}, [flowInstance, hasDetailPanel, nodes.length, ontologyUri]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
[setEdges]
@@ -316,7 +134,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewClass", type: "owl:Class", entityType: "class" },
data: { label: "NewClass", type: "owl:Class" },
};
setNodes((nds) => [...nds, newNode]);
setDraftDiff((prev) => ({
@@ -352,7 +170,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewIndividual", type: "owl:NamedIndividual", entityType: "external" },
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
};
setNodes((nds) => [...nds, newNode]);
}, [setNodes]);
@@ -372,21 +190,13 @@ export function OntologyEditor() {
}, []);
const autoLayout = useCallback(() => {
setNodes(layoutEditorNodes(nodes));
const layoutNodes = nodes.map((node, index) => ({
...node,
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
}));
setNodes(layoutNodes);
}, [nodes, setNodes]);
const selectNode = useCallback((node: OntologyNode) => {
setSelectedElement(node);
try {
const params = new URLSearchParams(window.location.search);
params.set("ontologyTab", "editor");
params.set("ontologyEntity", node.id);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; the editor selection still works without it.
}
}, []);
const saveDraft = useCallback(async () => {
if (!ontologyUri) {
alert("Please select an ontology first");
@@ -437,11 +247,12 @@ export function OntologyEditor() {
...prev,
removed_properties: [...prev.removed_properties, target.id],
}));
} else if (isEditableEntityType(target.data.entityType)) {
} else {
setNodes((nds) => nds.filter((n) => n.id !== target.id));
setDraftDiff((prev) => target.data.entityType === "property"
? { ...prev, removed_properties: [...prev.removed_properties, target.id] }
: { ...prev, removed_classes: [...prev.removed_classes, target.id] });
setDraftDiff((prev) => ({
...prev,
removed_classes: [...prev.removed_classes, target.id],
}));
}
setSelectedElement(null);
}
@@ -450,21 +261,16 @@ export function OntologyEditor() {
const renameSelected = useCallback(() => {
const target = showContext?.element ?? selectedElement;
if (target && !("source" in target) && isEditableEntityType(target.data.entityType)) {
if (target && !("source" in target)) {
const newLabel = prompt("Enter new name:", String(target.data.label ?? ""));
if (newLabel) {
setNodes((nds) =>
nds.map((n) => (n.id === target.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
);
setDraftDiff((prev) => target.data.entityType === "property"
? {
...prev,
modified_properties: { ...prev.modified_properties, [target.id]: { label: newLabel } },
}
: {
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
});
setDraftDiff((prev) => ({
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
}));
}
}
setShowContext(null);
@@ -533,10 +339,11 @@ export function OntologyEditor() {
};
const detailPanelStyle: React.CSSProperties = {
flex: "0 0 320px",
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "320px",
minWidth: "320px",
boxSizing: "border-box",
background: "rgba(9, 19, 34, 0.95)",
borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
padding: "20px",
@@ -546,24 +353,11 @@ export function OntologyEditor() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#07111f" }}>
<style>{ontologyFlowThemeCss}</style>
<div style={toolbarStyle}>
<select
aria-label="Active ontology"
value={ontologyUri}
onChange={(event) => {
setOntologyUri(event.target.value);
setSelectedElement(null);
try {
// Drop the previous ontology's entity from the URL, or a reload
// would resolve the stale ID and jump back to that ontology.
const params = new URLSearchParams(window.location.search);
params.delete("ontologyEntity");
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// URL state is optional; switching ontologies still works.
}
}}
onChange={(event) => setOntologyUri(event.target.value)}
style={selectStyle}
>
<option value="">Select ontology...</option>
@@ -604,75 +398,43 @@ export function OntologyEditor() {
</button>
</div>
<div style={{ display: "flex", flex: 1, minHeight: 0, minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, position: "relative" }}>
<ReactFlow
className="ontology-editor-flow"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setFlowInstance}
onNodeClick={(_, node) => selectNode(node)}
onEdgeClick={(_, edge) => setSelectedElement(edge)}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
nodeTypes={nodeTypes}
fitView
style={{ background: "#07111f" }}
>
<Background color="#1a2d3d" gap={20} />
<Controls />
<MiniMap {...ONTOLOGY_MINIMAP_THEME} />
</ReactFlow>
<div style={{ flex: 1, position: "relative" }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedElement(node)}
onEdgeClick={(_, edge) => setSelectedElement(edge)}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
nodeTypes={nodeTypes}
fitView
style={{ background: "#07111f" }}
>
<Background color="#1a2d3d" gap={20} />
<Controls />
<MiniMap nodeColor="#4aa3ff" maskColor="rgba(0,0,0,0.6)" />
</ReactFlow>
{isLoadingGraph && (
<div style={canvasMessageStyle}>Loading ontology structure</div>
)}
{!isLoadingGraph && graphError && (
<div style={{ ...canvasMessageStyle, color: "#ff9a8d" }}>{graphError}</div>
)}
{!isLoadingGraph && !graphError && ontologyUri && nodes.length === 0 && (
<div style={canvasMessageStyle}>This ontology has no editable classes or properties.</div>
)}
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
{"source" in showContext.element || isEditableEntityType(showContext.element.data.entityType) ? (
<>
{!("source" in showContext.element) && (
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
</div>
)}
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</>
) : (
<div style={{ ...contextItemStyle, cursor: "default", color: "#8fa8c6" }}>
This term is read-only
</div>
)}
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
</div>
)}
</div>
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</div>
)}
{selectedElement && (
<div style={detailPanelStyle}>
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
{"source" in selectedElement
? "Relationship Details"
: selectedElement.data.entityType === "property"
? "Property Details"
: selectedElement.data.entityType === "ontology"
? "Ontology Details"
: selectedElement.data.entityType === "external"
? "External Term Details"
: "Class Details"}
{"source" in selectedElement ? "Property Details" : "Class Details"}
</h3>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
@@ -691,9 +453,7 @@ export function OntologyEditor() {
<input
type="text"
value={String(selectedElement.data.label ?? "")}
readOnly={!isEditableEntityType(selectedElement.data.entityType)}
onChange={(e) => {
if (!isEditableEntityType(selectedElement.data.entityType)) return;
setNodes((nds) =>
nds.map((n) =>
n.id === selectedElement.id
@@ -703,19 +463,10 @@ export function OntologyEditor() {
);
setDraftDiff((prev) => ({
...prev,
...(selectedElement.data.entityType === "property"
? {
modified_properties: {
...prev.modified_properties,
[selectedElement.id]: { label: e.target.value },
},
}
: {
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
}),
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
}));
}}
style={{
@@ -745,17 +496,3 @@ export function OntologyEditor() {
</div>
);
}
const canvasMessageStyle: React.CSSProperties = {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
padding: "10px 14px",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.18)",
background: "rgba(3, 9, 18, 0.9)",
color: "#8fa8c6",
fontSize: "13px",
pointerEvents: "none",
};
@@ -9,32 +9,6 @@ import type {
ShaclValidationResponse,
} from "./types";
export type OntologyGraphNode = {
id: string;
type: string;
content?: string;
properties?: Record<string, unknown>;
};
export type OntologyGraphEdge = {
id?: string;
source: string;
target: string;
type: string;
weight?: number;
properties?: Record<string, unknown>;
};
export type OntologyGraphResponse = {
uri: string;
nodes: OntologyGraphNode[];
edges: OntologyGraphEdge[];
};
export type OntologyEntityOwner = {
source_ontology?: string;
};
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let detail = `Request failed with status ${response.status}`;
@@ -57,18 +31,6 @@ export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
}
export async function loadOntologyGraph(uri: string, signal?: AbortSignal): Promise<OntologyGraphResponse> {
return parseResponse<OntologyGraphResponse>(
await fetch(`/api/ontology/graph?uri=${encodeURIComponent(uri)}`, { signal }),
);
}
export async function loadOntologyEntityOwner(uri: string): Promise<string | undefined> {
const response = await fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`);
if (!response.ok) return undefined;
return (await response.json() as OntologyEntityOwner).source_ontology;
}
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
@@ -38,7 +38,6 @@ function readTabParam(): OntologyHubTab {
const params = new URLSearchParams(window.location.search);
const raw = params.get(TAB_PARAM);
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
if (params.get("ontologyEntity")) return "editor";
} catch {
// ignore
}
@@ -117,3 +116,4 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
</div>
);
}
@@ -1,70 +0,0 @@
export type EditorEntityType = "ontology" | "class" | "property" | "external";
export type RegistryEntry = {
uri: string;
name: string;
};
export const ONTOLOGY_MINIMAP_THEME = {
bgColor: "#0b1625",
maskColor: "rgba(7, 17, 31, 0.72)",
maskStrokeColor: "#5faeff",
maskStrokeWidth: 2,
nodeColor: "#2d7fd3",
nodeStrokeColor: "#9acbff",
nodeStrokeWidth: 1,
style: {
border: "1px solid #29435c",
borderRadius: 6,
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.32)",
},
} as const;
// The backend emits node types in compact (owl:Class) or full IRI
// (http://www.w3.org/2002/07/owl#Class) form; classification must accept both.
const FULL_IRI_PREFIXES: Array<[string, string]> = [
["http://www.w3.org/2002/07/owl#", "owl:"],
["http://www.w3.org/2000/01/rdf-schema#", "rdfs:"],
["http://www.w3.org/2004/02/skos/core#", "skos:"],
];
export function compactNodeType(type: string): string {
for (const [iri, prefix] of FULL_IRI_PREFIXES) {
if (type.startsWith(iri)) {
return `${prefix}${type.slice(iri.length)}`;
}
}
return type;
}
export function classifyNodeType(rawType: string): EditorEntityType {
const type = compactNodeType(rawType);
if (type === "owl:Ontology") return "ontology";
if (type === "owl:Class" || type === "rdfs:Class") return "class";
if (type.includes("Property")) return "property";
return "external";
}
function ownsByNamespace(entityUri: string, ontologyUri: string): boolean {
const stem = ontologyUri.replace(/[/#]+$/, "");
return entityUri === ontologyUri
|| entityUri.startsWith(`${stem}#`)
|| entityUri.startsWith(`${stem}/`);
}
export function inferOntologyUri(
entries: RegistryEntry[],
entityUri: string,
explicitOwner?: string,
): string | undefined {
if (explicitOwner && entries.some((entry) => entry.uri === explicitOwner)) {
return explicitOwner;
}
return [...entries]
.filter((entry) => ownsByNamespace(entityUri, entry.uri))
.sort((left, right) => right.uri.length - left.uri.length)[0]?.uri;
}
export function isEditableEntityType(entityType?: EditorEntityType): boolean {
return entityType === "class" || entityType === "property";
}
@@ -1,66 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
classifyNodeType,
compactNodeType,
inferOntologyUri,
isEditableEntityType,
ONTOLOGY_MINIMAP_THEME,
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
const registry = [
{ uri: "https://example.test/foo", name: "Foo" },
{ uri: "https://example.test/foo/nested", name: "Nested" },
];
test("ontology inference requires a URI delimiter and prefers the closest namespace", () => {
assert.equal(inferOntologyUri(registry, "https://example.test/foobar/Class"), undefined);
assert.equal(
inferOntologyUri(registry, "https://example.test/foo/nested#Class"),
"https://example.test/foo/nested",
);
});
test("explicit scheme ownership wins when an entity uses another namespace", () => {
assert.equal(
inferOntologyUri(registry, "https://vocabulary.test/Class", "https://example.test/foo"),
"https://example.test/foo",
);
});
test("only draft-supported class and property nodes are editable", () => {
assert.equal(isEditableEntityType("class"), true);
assert.equal(isEditableEntityType("property"), true);
assert.equal(isEditableEntityType("ontology"), false);
assert.equal(isEditableEntityType("external"), false);
});
test("the ontology minimap has an explicit dark, high-contrast theme", () => {
assert.equal(ONTOLOGY_MINIMAP_THEME.bgColor, "#0b1625");
assert.equal(ONTOLOGY_MINIMAP_THEME.maskStrokeColor, "#5faeff");
assert.equal(ONTOLOGY_MINIMAP_THEME.nodeStrokeColor, "#9acbff");
assert.match(ONTOLOGY_MINIMAP_THEME.style.border, /#29435c/);
});
test("node types classify identically in compact and full IRI form", () => {
const cases: Array<[string, string, string]> = [
["owl:Ontology", "http://www.w3.org/2002/07/owl#Ontology", "ontology"],
["owl:Class", "http://www.w3.org/2002/07/owl#Class", "class"],
["rdfs:Class", "http://www.w3.org/2000/01/rdf-schema#Class", "class"],
["owl:ObjectProperty", "http://www.w3.org/2002/07/owl#ObjectProperty", "property"],
["owl:DatatypeProperty", "http://www.w3.org/2002/07/owl#DatatypeProperty", "property"],
["owl:AnnotationProperty", "http://www.w3.org/2002/07/owl#AnnotationProperty", "property"],
];
for (const [compact, fullIri, expected] of cases) {
assert.equal(classifyNodeType(compact), expected, compact);
assert.equal(classifyNodeType(fullIri), expected, fullIri);
}
assert.equal(classifyNodeType("owl:NamedIndividual"), "external");
assert.equal(classifyNodeType("http://www.w3.org/2004/02/skos/core#Concept"), "external");
});
test("compactNodeType leaves unknown namespaces untouched", () => {
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
assert.equal(compactNodeType("owl:Class"), "owl:Class");
});
+6 -160
View File
@@ -234,12 +234,6 @@ class EntityDetailResponse(BaseModel):
properties: Dict[str, Any] = Field(default_factory=dict)
class OntologyGraphResponse(BaseModel):
uri: str
nodes: List[Dict[str, Any]] = Field(default_factory=list)
edges: List[Dict[str, Any]] = Field(default_factory=list)
class SKOSScheme(BaseModel):
uri: str
title: str
@@ -670,7 +664,6 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
"rdfs:label": cls.get("label", cls.get("name", "")),
"rdfs:comment": cls.get("description", ""),
"uri": cls_uri,
"scheme_uri": ontology_uri,
},
}
nodes.append(node)
@@ -687,21 +680,14 @@ def _convert_ontology_to_graph(ontology_dict: Dict[str, Any]) -> Tuple[List[Dict
# Add property nodes and edges
for prop in ontology_dict.get("properties", []):
prop_uri = prop.get("uri", f"temp:prop:{uuid.uuid4().hex[:12]}")
property_type = {
"object": "owl:ObjectProperty",
"data": "owl:DatatypeProperty",
"datatype": "owl:DatatypeProperty",
"annotation": "owl:AnnotationProperty",
}.get(str(prop.get("type", "object")).lower(), "owl:ObjectProperty")
node = {
"id": prop_uri,
"type": property_type,
"type": f"owl:{prop.get('type', 'Object').title()}Property",
"content": prop.get("name", prop.get("label", "")),
"properties": {
"rdfs:label": prop.get("label", prop.get("name", "")),
"rdfs:comment": prop.get("description", ""),
"uri": prop_uri,
"scheme_uri": ontology_uri,
},
}
nodes.append(node)
@@ -762,38 +748,14 @@ def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
)
def _node_belongs_to_ontology(
node: Dict[str, Any],
ontology_uri: str,
known_ontology_uris: Optional[set[str]] = None,
) -> bool:
def _node_belongs_to_ontology(node: Dict[str, Any], ontology_uri: str) -> bool:
nid = node.get("id", "")
if nid == ontology_uri:
return True
owner = _node_source_ontology(node)
if owner:
return owner == ontology_uri
if known_ontology_uris:
namespace_owners = [
candidate
for candidate in known_ontology_uris
if nid == candidate
or nid.startswith(
(candidate.rstrip("#/") + "#", candidate.rstrip("#/") + "/")
)
]
if namespace_owners and max(namespace_owners, key=len) != ontology_uri:
return False
if _node_source_ontology(node) == ontology_uri:
return True
stem = ontology_uri.rstrip("#/")
if not nid.startswith((stem + "#", stem + "/")):
return False
# Prefix ownership only extends to names minted directly in the
# ontology's namespace (<stem>#Term or <stem>/Term). Any further
# delimiter marks a nested vocabulary (<stem>/child#Term,
# <stem>/child/Term), which must not be absorbed into the parent
# until it is registered or carries an explicit owner.
local_name = nid[len(stem) + 1 :]
return "#" not in local_name and "/" not in local_name
return nid.startswith((stem + "#", stem + "/"))
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
@@ -1259,8 +1221,7 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
metadata.setdefault("description", str(obj))
break
synthetic_uri = "uri" not in metadata
if synthetic_uri:
if "uri" not in metadata:
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
metadata["triple_count"] = len(g)
@@ -1307,20 +1268,6 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
"weight": 1.0,
})
if synthetic_uri:
# No owl:Ontology / skos:ConceptScheme declaration exists, so the
# synthetic registry URI shares no namespace with any node. Ownership
# must be recorded explicitly, and the editor needs a matching graph
# node, or the registered ontology resolves to an empty core and 404s.
for node in nodes:
node["properties"].setdefault("scheme_uri", metadata["uri"])
nodes.append({
"id": metadata["uri"],
"type": "owl:Ontology",
"content": metadata["name"],
"properties": {"rdfs:label": metadata["name"], "uri": metadata["uri"]},
})
return nodes, edges, metadata
@@ -1821,107 +1768,6 @@ async def search_entities(
return results
@router.get("/graph", response_model=OntologyGraphResponse)
async def get_ontology_graph(
request: Request,
uri: str = Query(..., min_length=1),
session: GraphSession = Depends(get_session),
):
"""Return the editable schema subgraph for one registered ontology."""
registry = _get_registry(request)
ontology_nodes: List[Dict[str, Any]] = []
for node_type in _ONTOLOGY_TYPES:
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
)
ontology_nodes.extend(nodes)
known_ontology_uris = set(registry) | {
str(node.get("id", "")) for node in ontology_nodes if node.get("id")
}
if uri not in known_ontology_uris:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
schema_types = _CLASS_TYPES | _PROPERTY_TYPES | _CONCEPT_TYPES | _ONTOLOGY_TYPES
candidates_by_id: Dict[str, Dict[str, Any]] = {}
for node_type in schema_types:
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type=node_type, skip=0, limit=2**63 - 1
)
candidates_by_id.update(
(str(node.get("id", "")), node) for node in nodes if node.get("id")
)
core_node_ids = {
str(node.get("id", ""))
for node in candidates_by_id.values()
if _node_belongs_to_ontology(node, uri, known_ontology_uris)
}
if not core_node_ids:
raise HTTPException(status_code=404, detail="Ontology graph not found.")
structure_edge_types = {
"rdf:type",
"rdfs:subClassOf",
"rdfs:domain",
"rdfs:range",
"owl:disjointWith",
"owl:equivalentClass",
"owl:equivalentProperty",
"owl:inverseOf",
"skos:broader",
"skos:narrower",
"skos:related",
}
selected_edges: List[Dict[str, Any]] = []
for edge_type in structure_edge_types:
edges, _ = await asyncio.to_thread(
session.get_edges,
edge_type=edge_type,
skip=0,
limit=2**63 - 1,
)
# Keep only edges whose source is a core node: the requested ontology
# may reference outward (e.g. rdfs:range to an external vocabulary),
# but an unrelated ontology's property pointing at a core class must
# not leak inward.
selected_edges.extend(
edge for edge in edges
if str(edge.get("source", "")) in core_node_ids
)
if (
len(core_node_ids) > _MAX_ANALYSIS_NODES
or len(selected_edges) > _MAX_ANALYSIS_NODES
):
raise HTTPException(
status_code=413,
detail=(
"Ontology editor graph exceeds the maximum size "
f"({_MAX_ANALYSIS_NODES} nodes or edges)."
),
)
selected_node_ids = set(core_node_ids)
for edge in selected_edges:
selected_node_ids.add(str(edge.get("source", "")))
selected_node_ids.add(str(edge.get("target", "")))
selected_nodes = [candidates_by_id[node_id] for node_id in core_node_ids]
for node_id in selected_node_ids - core_node_ids:
external = await asyncio.to_thread(session.get_node, node_id)
if external is not None:
selected_nodes.append(external)
selected_nodes.sort(key=lambda node: str(node.get("id", "")))
selected_edges.sort(
key=lambda edge: (
str(edge.get("source", "")),
str(edge.get("type", "")),
str(edge.get("target", "")),
str(edge.get("id", "")),
)
)
return OntologyGraphResponse(uri=uri, nodes=selected_nodes, edges=selected_edges)
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
async def get_entity_detail(
entity_uri: str,
+1 -24
View File
@@ -37,29 +37,6 @@ from .naming_conventions import NamingConventions
from .relationship_utils import build_entity_aliases, resolve_relationship_endpoint_type
# Top-level entity keys that describe structure or provenance rather than
# business attributes. GraphBuilder and EntityMerger attach these to entity
# dicts (relationships list, nested properties/metadata maps, merge history),
# so they must not be inferred as datatype properties. Each key mirrors what
# the framework actually writes to a merged entity top level
# (see MergeStrategyManager._merge_entities merged_entity dict and GraphBuilder).
_CONTROL_FIELDS = frozenset(
{
"id",
"type",
"entity_type",
"text",
"label",
"confidence",
"properties",
"relationships",
"metadata",
"merged_from",
"merge_strategy",
}
)
class PropertyGenerator:
"""
Property generation engine for ontologies.
@@ -370,7 +347,7 @@ class PropertyGenerator:
for entity in entities:
for key, value in entity.items():
if key in _CONTROL_FIELDS:
if key in ["id", "type", "entity_type", "text", "label", "confidence"]:
continue
# Infer type
+3 -203
View File
@@ -12,11 +12,7 @@ from semantica.context.context_graph import ContextGraph
pytest.importorskip("fastapi")
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.ontology import ( # noqa: E402
OntologyEntry,
_convert_ontology_to_graph,
_node_belongs_to_ontology,
)
from semantica.explorer.routes.ontology import OntologyEntry # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
@@ -135,181 +131,6 @@ def test_health_returns_dimensions_and_issues(client):
assert isinstance(payload["issues"], list)
def test_ontology_graph_returns_editable_schema_nodes_and_edges(client):
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
payload = response.json()
node_ids = {node["id"] for node in payload["nodes"]}
assert "http://example.org/onto-a" in node_ids
assert "http://example.org/onto-a#Person" in node_ids
assert "http://example.org/onto-a#name" in node_ids
assert any(
edge["source"] == "http://example.org/onto-a#name"
and edge["target"] == "http://example.org/onto-a#Person"
and edge["type"] == "rdfs:domain"
for edge in payload["edges"]
)
def test_ontology_graph_rejects_unregistered_namespace(client):
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org"},
)
assert response.status_code == 404
def test_ontology_graph_excludes_separately_registered_nested_ontology(client):
graph = client.app.state.session.graph
nested = "http://example.org/onto-a/nested"
nested_class = f"{nested}#PrivateClass"
graph.add_node(nested, node_type="owl:Ontology", content="Nested Ontology")
graph.add_node(nested_class, node_type="owl:Class", content="Private Class")
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
node_ids = {node["id"] for node in response.json()["nodes"]}
assert nested not in node_ids
assert nested_class not in node_ids
def test_ontology_graph_prefers_explicit_ownership_over_uri_namespace(client):
graph = client.app.state.session.graph
explicit_member = "http://unrelated.example/Person"
graph.add_node(
explicit_member,
node_type="owl:Class",
content="Explicit Member",
scheme_uri="http://example.org/onto-a",
)
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
assert explicit_member in {node["id"] for node in response.json()["nodes"]}
def test_ontology_graph_excludes_inward_edges_from_other_ontologies(client):
graph = client.app.state.session.graph
foreign_prop = "http://example.org/onto-b#recordOf"
graph.add_node(
foreign_prop,
node_type="owl:ObjectProperty",
content="record of",
scheme_uri="http://example.org/onto-b",
)
# onto-b's property points its domain at onto-a's class: an inward
# reference that must not pull the foreign property into onto-a's graph.
graph.add_edge(foreign_prop, "http://example.org/onto-a#Person", edge_type="rdfs:domain")
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
payload = response.json()
assert foreign_prop not in {node["id"] for node in payload["nodes"]}
assert all(edge["source"] != foreign_prop for edge in payload["edges"])
def test_ontology_graph_excludes_unregistered_nested_namespace(client):
graph = client.app.state.session.graph
nested_class = "http://example.org/onto-a/vocab#Term"
graph.add_node(nested_class, node_type="owl:Class", content="Nested Term")
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
assert nested_class not in {node["id"] for node in response.json()["nodes"]}
def test_node_belongs_to_ontology_nested_namespace_matrix():
parent = "http://example.org/onto-a"
child = "http://example.org/onto-a/nested"
def node(node_id):
return {"id": node_id, "properties": {}}
assert _node_belongs_to_ontology(node(f"{parent}#Person"), parent, {parent})
assert _node_belongs_to_ontology(node(f"{parent}/Person"), parent, {parent})
# An unregistered nested namespace is not absorbed into the parent,
# whether fragment-based or path-based
assert not _node_belongs_to_ontology(node(f"{child}#Term"), parent, {parent})
assert not _node_belongs_to_ontology(node(f"{child}/Term"), parent, {parent})
# Once registered, the nested namespace owns its nodes
assert not _node_belongs_to_ontology(node(f"{child}#Term"), parent, {parent, child})
assert _node_belongs_to_ontology(node(f"{child}#Term"), child, {parent, child})
assert _node_belongs_to_ontology(node(f"{child}/Term"), child, {parent, child})
def test_load_fallback_import_without_declaration_is_editable(client):
turtle = """
@prefix ex: <http://data.example.org/people#> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:Employee a rdfs:Class ;
rdfs:label "Employee" .
ex:manager a rdf:Property ;
rdfs:label "manager" .
"""
with patch(
"semantica.ingest.ontology_ingestor.OntologyIngestor.ingest_ontology",
side_effect=RuntimeError("force fallback parser"),
):
loaded = client.post(
"/api/ontology/load",
json={"content": turtle, "format": "turtle"},
)
assert loaded.status_code == 200
uri = loaded.json()["uri"]
assert uri.startswith("urn:semantica:onto:")
response = client.get("/api/ontology/graph", params={"uri": uri})
assert response.status_code == 200
payload = response.json()
node_ids = {node["id"] for node in payload["nodes"]}
assert uri in node_ids
assert "http://data.example.org/people#Employee" in node_ids
def test_ontology_graph_ignores_unrelated_data_when_enforcing_size_limit(client):
graph = client.app.state.session.graph
for index in range(5_001):
graph.add_node(
f"urn:unrelated:{index}",
node_type="owl:Class",
content="Unrelated",
scheme_uri="http://example.org/onto-b",
)
response = client.get(
"/api/ontology/graph",
params={"uri": "http://example.org/onto-a"},
)
assert response.status_code == 200
assert "http://example.org/onto-a#Person" in {
node["id"] for node in response.json()["nodes"]
}
def test_shacl_generate_and_shapes(client):
response = client.post(
"/api/ontology/shacl/generate",
@@ -875,29 +696,6 @@ def test_ontology_load_does_not_swallow_422_from_ingestor_success_path(client):
fallback_parse.assert_not_called()
def test_convert_ontology_uses_standard_property_types_and_scheme_uri():
ontology_uri = "http://example.org/onto"
nodes, _ = _convert_ontology_to_graph(
{
"uri": ontology_uri,
"name": "Example Ontology",
"classes": [
{"uri": f"{ontology_uri}#Person", "name": "Person"},
],
"properties": [
{"uri": f"{ontology_uri}#name", "name": "name", "type": "data"},
{"uri": f"{ontology_uri}#knows", "name": "knows", "type": "object"},
],
}
)
by_id = {node["id"]: node for node in nodes}
assert by_id[f"{ontology_uri}#Person"]["properties"]["scheme_uri"] == ontology_uri
assert by_id[f"{ontology_uri}#name"]["type"] == "owl:DatatypeProperty"
assert by_id[f"{ontology_uri}#knows"]["type"] == "owl:ObjectProperty"
assert by_id[f"{ontology_uri}#name"]["properties"]["scheme_uri"] == ontology_uri
# ---------------------------------------------------------------------------
# refresh_ontology — single combined add_nodes_and_edges() coverage (#775)
# ---------------------------------------------------------------------------
@@ -991,3 +789,5 @@ def test_refresh_ontology_missing_source_url_returns_422(client):
response = client.post(f"/api/ontology/{encoded_uri}/refresh")
assert response.status_code == 422
assert "source url" in response.json()["detail"].lower()
@@ -1,94 +0,0 @@
"""Framework/control entity keys must not be inferred as datatype properties.
The _CONTROL_FIELDS skip set mirrors exactly what the framework writes to a
merged entity's top level (see MergeStrategyManager._merge_entities): no extra
guesses, so business attributes that merely share a common name (e.g. source)
keep getting inferred.
"""
from semantica.deduplication.merge_strategy import MergeStrategyManager
from semantica.ontology.property_generator import PropertyGenerator
def _merged_entity():
"""Run a real merge so the entity carries the framework's actual top-level keys."""
manager = MergeStrategyManager(default_strategy="keep_most_complete")
result = manager.merge_entities(
[
{"id": "b1", "name": "Hangzhou Branch", "type": "ORG", "employee_count": 120},
{"id": "b2", "name": "Hangzhou Branch", "type": "ORG"},
]
)
return result.merged_entity
def test_framework_fields_not_inferred_as_data_properties():
entity = _merged_entity()
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
properties = PropertyGenerator().infer_properties([entity], [], classes)
names = {p["name"] for p in properties}
for framed in (
"properties",
"relationships",
"metadata",
"merged_from",
"merge_strategy",
):
assert framed not in names, f"framework field {framed} leaked as a property"
def test_business_attributes_still_inferred():
entity = _merged_entity()
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
properties = PropertyGenerator().infer_properties([entity], [], classes)
names = {p["name"] for p in properties}
assert "name" in names
assert "metadata" not in names
def test_source_field_still_inferred_as_business_attribute():
"""A top-level 'source' is a business attribute, not a framework field."""
entity = {
"id": "b1",
"name": "Hangzhou Branch",
"type": "ORG",
"source": "doc-42",
}
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
properties = PropertyGenerator().infer_properties([entity], [], classes)
names = {p["name"] for p in properties}
assert "source" in names
def test_unmerged_graphbuilder_entities_infer_business_attributes():
"""Flat entities from GraphBuilder (merge_entities=False) must not lose business
attributes through _CONTROL_FIELDS: name and domain-specific fields must be
inferred, and none of the framework keys should appear in the output."""
from semantica.kg.graph_builder import GraphBuilder
builder = GraphBuilder(merge_entities=False, resolve_conflicts=False)
graph = builder.build(
{
"entities": [
{"id": "c1", "name": "Chengdu Plant", "type": "ORG", "headcount": 300},
{"id": "c2", "name": "Wuhan Plant", "type": "ORG", "headcount": 450},
],
"relationships": [],
}
)
entities = graph["entities"]
classes = [{"name": "Organization", "metadata": {"inferred_from": "ORG"}}]
properties = PropertyGenerator().infer_properties(entities, [], classes)
names = {p["name"] for p in properties}
assert "name" in names, "name must be inferred from flat GraphBuilder entities"
assert "headcount" in names, "domain business attribute must be inferred"
for framed in ("properties", "relationships", "metadata", "merged_from", "merge_strategy"):
assert framed not in names, f"framework field {framed!r} must not appear"