mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-04 04:01:07 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2cb70fda | ||
|
|
c5224aaa75 |
@@ -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
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user