Compare commits

..
Author SHA1 Message Date
KaifAhmad1 dc306079ea docs(index): rewrite landing page as a crisp developer welcome
Replace the long feature-dump landing page with a lean "Welcome to
Semantica" page: a two-line problem/positioning statement (deterministic
semantic layer, no LLM required for graph construction, reasoning, or
provenance), five capability bullets, the multi-provider quickstart
snippet, and a 4-step onboarding path. Drops the redundant module
table, industry-use-case grid, and duplicate link lists in favor of
linking out to Core Concepts, guides, and the API reference. Keeps a
collapsed module-list accordion so the page still satisfies
docs_check.py's full-module-coverage check.
2026-09-03 22:21:30 +05:30
Wei Tao dd1e654047 fix(explorer): load registered schemas in Ontology Editor (#1278)
The Ontology Hub editor selected a registered ontology but left the canvas empty, and opening an ontology deep link landed on the Welcome workspace instead of the editor. Two independent causes: the application shell ignored `ontologyTab`/`ontologyEntity` URL state at startup, and the editor loaded registry metadata but never fetched the selected ontology's schema nodes and structural edges. The backend now exposes a bounded schema subgraph for one ontology at `GET /api/ontology/graph?uri=...`, and the editor maps that response into React Flow nodes and edges with loading, error, selection, and layout handling.

Five things came out of review on the new endpoint and the editor that consumes it.

The edge selection originally included an edge whenever either its source or its target was a core node. That let a property owned by a completely unrelated ontology leak into the requested one just because its `rdfs:domain` or `rdfs:range` happened to point at one of the requested ontology's classes. Edges are now selected only when their source is a core node, so the requested ontology can still reference outward to external vocabulary, but nothing from an unrelated ontology gets pulled in the other direction.

The backend accepts both compact and full-IRI forms for node types (`owl:Class` and `http://www.w3.org/2002/07/owl#Class` are equivalent), but the frontend classifier only recognized the compact strings, so a full-IRI class or ontology node fell through to `"external"`, wrong panel, wrongly read-only. Classification moved into `ontologyEditorModel.ts` as `classifyNodeType`, which compacts known full IRIs before matching.

An ontology imported through the fallback RDF parser, one with no `owl:Ontology` or `skos:ConceptScheme` declaration, minted a synthetic registry URI but never created a matching graph node or set `scheme_uri` on the classes and properties it imported. `_node_belongs_to_ontology` had nothing to associate those nodes with, so `core_node_ids` ended up empty and the endpoint 404'd for a registered ontology that genuinely had data. The fallback parser now records that ownership and emits a matching `owl:Ontology` node whenever it has to synthesize a URI.

Nested namespaces that were never registered as their own ontology got silently absorbed into whichever parent prefix matched, in both directions: a fragment-delimited nested name (`<stem>/child#Term`) and a path-delimited one (`<stem>/child/Term`). The first fix only handled the fragment form; prefix ownership now only extends to names minted directly in the ontology's own namespace (`<stem>#Term` or `<stem>/Term`), and any further delimiter of either kind marks a nested vocabulary that isn't absorbed until it's registered or carries an explicit owner. Once registered, the nested namespace owns its own nodes as before.

Selecting a node in the editor writes `ontologyEntity=<id>` into the URL. Switching ontologies via the dropdown cleared the in-memory selection but left that parameter pointing at the old ontology, so a reload after switching could resolve the stale ID and jump back. The dropdown now clears the parameter on change.

Regression tests cover each fix directly: inward-edge exclusion, the full-IRI classification matrix, an end-to-end fallback-import test that forces the parser path and opens the resulting ontology, and a nested-namespace ownership matrix covering both delimiter forms in both the registered and unregistered case.
2026-09-03 17:58:20 +05:00
KevinandSameer Kadam 6b8437781e fix(ontology): stop inferring framework entity fields as datatype properties (#1420)
* fix(ontology): stop inferring framework entity fields as datatype properties

* test(ontology): assert framework fields do not leak as datatype properties

* test(ontology): fix test file formatting

* test(ontology): cover unmerged graphbuilder entities

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-03 18:24:52 +05:30
Zohaib Hassnain ba85215aea docs(graphrag): fix broken string literals and clarify max_hops (#1431)
The Banking domain example built basel_cre20_text / bcbs239_text with bare indented string continuations (no parens, no backslash), raising IndentationError. Wrapped both in parentheses like the Clinical example. Separately, the guide passed max_hops= to retrieve() and stated it overrides the constructor's expansion depth: it does not. AgentContext.retrieve(max_hops=) is only consumed by _apply_proximity_metadata (a proximity-radius filter that needs anchor_node), and expansion depth is fixed by max_expansion_hops passed into ContextRetriever. Removed max_hops from the non-anchored retrieve call, annotated the anchored ones, corrected the intro and tuning sections, and noted query_with_reasoning() does take a real per-call max_hops. Also replaced an invented node/edge count comment with the real store() return keys and qualified an ingest_file() reference.
2026-09-03 17:48:07 +05:00
12 changed files with 1022 additions and 371 deletions
+24 -293
View File
@@ -1,5 +1,5 @@
---
title: "Semantica"
title: "Welcome to Semantica"
description: "The Context and Semantic Layer for AI in High-Stakes Domains: Context Graphs · Decision Intelligence · Full Provenance"
---
@@ -7,93 +7,25 @@ description: "The Context and Semantic Layer for AI in High-Stakes Domains: Cont
pip install semantica
```
Most AI agents store embeddings, not meaning. They can't say why a fact was recalled, where it came from, or what led to a decision. In healthcare, finance, legal, and government, that lack of a traceable record blocks production deployment.
Most AI agents run on embeddings, not meaning. A similarity score has no structure, no relationships, and no way to explain why a result came back.
Semantica is the context and semantic layer for AI in high-stakes domains, sitting beneath your existing agent framework. It doesn't replace LangChain or LlamaIndex; it makes their outputs traceable.
Semantica is the semantic and context layer underneath your LLM, vector store, and agent framework: deterministic infrastructure, not a model. Graph construction, reasoning, and provenance all run without an LLM in the loop. It turns fragmented enterprise data into a structured, queryable context graph and knowledge graph, governed by ontologies, taxonomies, and controlled vocabularies (OWL, SHACL, SKOS), so your data's meaning is explicit rather than approximated by an embedding.
Provenance and audit trails aren't a bolt-on. They fall out naturally once your data has that structure, so the same graph that powers retrieval and reasoning also gives you a straight answer when a regulator asks why.
## What Most AI Stacks Are Missing
## What you get
**No memory structure.** Agents store embeddings, not meaning.
- No way to ask *why* a fact was recalled
- No link from a recalled fact back to its source document
- Context is a black box that resets on every run
**No decision trail.** Agents act continuously but record nothing.
- No history to hand to a regulator or auditor
- No way to replay or reproduce a past decision
- Debugging means re-running, not reviewing
**No provenance.** Outputs can't be traced to source facts.
- A hard compliance blocker in healthcare, finance, and legal
- No lineage from inference back to the original document
- No way to demonstrate what the agent actually relied on
**No reasoning transparency.** Black-box answers with no explanation.
- No way to validate the reasoning path
- No way to contest a specific conclusion
- No basis for improving or correcting future behavior
**No conflict detection.** Contradictory facts silently coexist in vector stores.
- No detection when two sources disagree
- Outputs become inconsistent and unpredictable over time
- Silent failures compound as the knowledge base grows
## What Semantica Adds to Your Stack
Semantica gives every agent the infrastructure it needs to be accountable, and it drops into an existing setup in minutes.
**Context Graphs.** A structured, queryable graph of everything your agent knows, decides, and reasons about.
- Persistent across agent runs, with no context loss between sessions
- Queryable with SPARQL and full graph algorithms
- Temporal model with `valid_from` / `valid_until` on nodes and edges
- Point-in-time snapshots of the full knowledge state
**Decision Intelligence.** Every decision is a first-class object in your system.
- `record_decision()` captures full lifecycle and causal chain
- Hybrid precedent search over past decisions for consistency
- `analyze_decision_impact()` shows downstream consequences
- Causal chain visualization from trigger to outcome
**Full Provenance.** Every fact links to its source document and ingestion event.
- W3C PROV-O compliant lineage across all modules
- Full traceability from raw input to final inference
- `recorded_at` stamping with OWL-Time export
- Audit-ready for HIPAA, SOX, GDPR, FDA 21 CFR Part 11
**Reasoning Engines.** Explainable reasoning paths, not black boxes.
- Forward chaining, Rete, deductive, abductive
- SPARQL query-based inference over RDF graphs
- Datalog with recursive Horn clause rules
- Every conclusion backed by a traceable derivation path
**Temporal Intelligence.** Your graph knows not just *what*, but *when*.
- Allen interval algebra covering all 13 temporal relations
- Point-in-time queries over historical graph states
- Temporal provenance stamping on every fact
- OWL-Time export for standards-compliant archiving
**Ontology Hub.** Full ontology lifecycle in the browser.
- Visual editor for schema design and editing
- SHACL Studio for constraint authoring and validation
- Alignment authoring across multiple ontologies
- Health dashboard and version control built in
- **[Context graphs](/guides/context-graphs)**: a persistent, queryable graph of everything your agent knows, decides, and reasons about
- **Decision intelligence**: `record_decision()` captures the full lifecycle and causal chain of every decision
- **[Full provenance](/guides/provenance)**: every fact links back to its source, W3C PROV-O compliant and audit-ready for HIPAA, SOX, and GDPR
- **[Explainable reasoning](/guides/reasoning)**: forward chaining, Datalog, and SPARQL, each with a derivation path you can inspect
- **Temporal intelligence**: Allen interval algebra and point-in-time snapshots, so the graph knows not just *what* but *when*
<Tip>
Works alongside any LLM provider and any agent framework. Add it to an existing stack without changing your architecture.
Works alongside any LLM provider and any agent framework, and ingests directly from enterprise data platforms like Databricks, SAP, Salesforce, and Snowflake. Add it to an existing stack without changing your architecture.
</Tip>
<img src="/assets/img/diagrams/architecture-overview.svg" alt="Semantica four-layer architecture: Ingestion → Processing → Intelligence → Application" style={{ width: '100%', borderRadius: '12px', margin: '24px 0' }} />
## See It In Action
One pip install. A few lines to connect your agent. Everything else becomes traceable.
```bash
pip install semantica
```
## Try it
<CodeGroup>
@@ -175,229 +107,28 @@ decision_id = context.record_decision(
</CodeGroup>
- [Full Quickstart](/quickstart): step-by-step pipeline walkthrough
- [Cookbook](/cookbook): 40+ real-world Jupyter notebooks
- [Join Discord](https://discord.gg/sV34vps5hH): community chat and support
## Industry Use Cases
Semantica is used in domains where every decision must be explainable and every fact must be traceable.
<Warning>
**This is system-level explainability, not foundation-model explainability.** Semantica does not expose, reconstruct, or explain what happens *inside* the LLM/foundation model. Its internal reasoning or chain-of-thought stays opaque, as it does for any external system. What Semantica explains is *outside* the model: the context and data fed in, the decision produced, its provenance, the relevant relationships, the policies applied, and the full execution trail. See [Core Concepts](/concepts) for the full scope note.
</Warning>
**Healthcare & Life Sciences**
- Clinical decision support with full audit trails
- Drug interaction and contraindication graphs
- Patient safety event tracking and root-cause analysis
- HIPAA-compliant provenance chains out of the box
**Finance & Risk**
- Fraud detection knowledge graphs
- Risk assessment trails built to survive an audit
- SOX, GDPR, and MiFID II compliance infrastructure
- Model decision lineage for regulatory reporting
**Legal & Compliance**
- Evidence-backed research with every cited fact provenance-linked
- Contract analysis with traceable clause extraction
- Regulatory change tracking across jurisdictions
- Full reasoning paths ready for court-admissible documentation
**Cybersecurity**
- Threat attribution graphs linking actors, TTPs, and indicators
- Incident response timelines with full event provenance
- Security audit trails across the complete kill chain
- MITRE ATT&CK-aligned knowledge graph integration
**Government & Defense**
- Policy decision trails from brief to outcome
- Classified information handling with provenance chains
- Chain-of-custody scrutiny for intelligence reporting
- Air-gapped deployment with local LLM support
**Critical Infrastructure**
- Power grid state tracking with temporal intelligence
- Transportation safety event graphs
- Emergency response coordination with decision audit trails
- Consequence modeling for high-stakes operational decisions
## Start Here
## Start here
<Steps>
<Step title="Install Semantica">
<Step title="Install">
```bash
pip install semantica
```
See [Installation](/installation) for optional extras (`[all]`, `[neo4j]`, `[pinecone]`) and environment setup.
Optional extras: `[all]`, `[neo4j]`, `[pinecone]`. See [Installation](/installation).
</Step>
<Step title="Run the Quickstart">
Build a complete knowledge graph pipeline in [5 minutes](/quickstart):
- Ingest documents from any source
- Extract entities and relationships
- Build and query the graph
- Record and trace a decision
<Step title="Build a pipeline">
Follow the [Quickstart](/quickstart) to ingest documents, extract entities, build a graph, and record a decision in 5 minutes.
</Step>
<Step title="Learn the mental model">
[Core Concepts](/concepts) covers:
- Knowledge graphs vs. vector stores: when to use each
- What GraphRAG is and how Semantica implements it
- How provenance and decision tracking work together
- The context and semantic layer architecture
<Step title="Learn the model">
[Core Concepts](/concepts) covers knowledge graphs vs. vector stores, GraphRAG, and how provenance and decisions fit together.
</Step>
<Step title="Go deep on any module">
Every module has a dedicated [reference page](/reference/context) with:
- Full class and method documentation
- Parameter tables with types and defaults
- Runnable code examples for each feature
<Step title="Go deep">
Every module has a [reference page](/reference/context) with full API docs and runnable examples.
</Step>
</Steps>
- [Installation](/installation): get Semantica installed in under a minute
- [Quickstart](/quickstart): build a complete knowledge graph pipeline in 5 minutes
- [Core Concepts](/concepts): the mental model behind the API
- [API Reference](/reference/context): exact module, class, and method details
- [Cookbook](/cookbook): domain notebooks for real-world use cases
- [Changelog](https://github.com/semantica-agi/semantica/releases): release history
## Full Capabilities
<AccordionGroup>
<Accordion title="Context & Decision Intelligence" icon="brain">
### Context Graphs
- Structured, persistent graph of entities, relationships, and decisions
- Temporal model with `valid_from` / `valid_until` on every node and edge
- Point-in-time queries across historical graph states
- Distance Intelligence: semantic neighborhoods and N×N distance matrices
### Decision Tracking
- `record_decision()` with full lifecycle management and causal chains
- Hybrid similarity search over past decisions for consistency enforcement
- `analyze_decision_impact()` and `analyze_decision_influence()` for consequence modeling
- Ego-mode exploration for targeted neighborhood investigation
More: the [Cookbook](/cookbook) for real-world notebooks, [Discord](https://discord.gg/sV34vps5hH) for help.
<Accordion title="Full module list">
`semantica.ingest`, `semantica.parse`, `semantica.split`, `semantica.normalize`, `semantica.semantic_extract`, `semantica.kg`, `semantica.ontology`, `semantica.reasoning`, `semantica.embeddings`, `semantica.vector_store`, `semantica.graph_store`, `semantica.triplet_store`, `semantica.context`, `semantica.provenance`, `semantica.change_management`, `semantica.deduplication`, `semantica.conflicts`, `semantica.export`, `semantica.visualization`, `semantica.pipeline`, `semantica.seed`, `semantica.llms`, `semantica.mcp_server`, `semantica.explorer`, `semantica.evals`, `semantica.utils`, `semantica.core`. See the [API Reference](/reference/context) for full docs on each.
</Accordion>
<Accordion title="Knowledge Engineering" icon="diagram-project">
### Entity & Relation Extraction
- Named entity recognition: pattern, ML, or LLM methods
- Typed triplet extraction via LLM or rule-based pipelines
- Event extraction with temporal and causal linking
### Ontology & Schema
- Ontology Hub: visual editor, SHACL Studio, alignments, health dashboard
- Deduplication v2: `blocking_v2`, `hybrid_v2`, `semantic_v2`: up to 7x faster
- Datalog reasoning: recursive Horn clause rules with fixpoint semantics
- SPARQL reasoning: query-based inference over RDF graphs
</Accordion>
<Accordion title="Provenance & Auditability" icon="shield-check">
### Lineage Tracking
- W3C PROV-O lineage across all modules: every fact has a source
- `recorded_at` stamping with full OWL-Time export
- Change management with SHA-256 checksums and version control
- Full audit trails from ingestion event to final inference
### Compliance Infrastructure
- HIPAA: patient data handling with audit-ready provenance chains
- SOX / MiFID II: financial decision records with full traceability
- GDPR: data lineage for subject access and right-to-erasure workflows
- FDA 21 CFR Part 11: electronic records and signature compliance
</Accordion>
<Accordion title="Data Ingestion & Export" icon="database">
### Ingestion Formats
- Documents: PDF, DOCX, HTML, PPTX, Docling layout analysis
- Structured data: JSON, CSV, Excel, Parquet, XML
- Sources: web crawl, SQL, Snowflake, feeds, email, code repositories, MCP
### Vector Stores
- FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector, in-memory
### Graph Stores
- Neo4j, FalkorDB, Apache AGE, Amazon Neptune
### Export Formats
- RDF: Turtle, JSON-LD, N-Triples, RDF/XML
- Tabular: Parquet, CSV, Arrow
- Graph: GraphML, GEXF, DOT, ArangoDB AQL
- Ontology: OWL, SKOS, SHACL
</Accordion>
</AccordionGroup>
## Module Reference
| Module | What it provides |
| :-------- | :----------------- |
| `semantica.context` | Context graphs, agent memory, decision tracking, causal analysis, precedent search |
| `semantica.kg` | KG construction, graph algorithms, temporal model, Allen interval algebra |
| `semantica.semantic_extract` | NER, relation extraction, event extraction, triplet generation |
| `semantica.reasoning` | Forward chaining, Rete, deductive, abductive, SPARQL, Datalog |
| `semantica.ontology` | SHACL, SKOS, alignments, diff/migration, auto-generation, OWL/RDF |
| `semantica.explorer` | FastAPI Knowledge Explorer, Ontology Hub, Distance Intelligence, SHACL Studio |
| `semantica.mcp_server` | MCP stdio server: 15 tools for Claude Desktop, VS Code, Cursor, Windsurf, Cline |
| `semantica.vector_store` | FAISS, Pinecone, Weaviate, Qdrant, Milvus, PgVector |
| `semantica.graph_store` | Neo4j, FalkorDB, Apache AGE, Amazon Neptune |
| `semantica.triplet_store` | In-memory and persistent RDF triple store with SPARQL |
| `semantica.ingest` | Files, web, feeds, databases, Snowflake, Parquet, XML, MCP |
| `semantica.parse` | Document parsing: PDF, DOCX, HTML, PPTX, Docling layout analysis |
| `semantica.split` | Text chunking: sentence, paragraph, token, semantic boundary strategies |
| `semantica.normalize` | Text normalization, entity canonicalization, whitespace and encoding cleanup |
| `semantica.embeddings` | Sentence-Transformers, FastEmbed, OpenAI, BGE, Ollama local embeddings |
| `semantica.pipeline` | Pipeline DSL, parallel workers, retry policies, failure handling |
| `semantica.export` | RDF, Parquet, ArangoDB AQL, CSV, OWL, Arrow, GraphML, GEXF, DOT |
| `semantica.visualization` | Programmatic graph rendering: force, hierarchical, circular, spring layouts |
| `semantica.deduplication` | Entity deduplication v1/v2, similarity scoring, blocking, merging |
| `semantica.conflicts` | Conflict detection and resolution across overlapping knowledge sources |
| `semantica.provenance` | W3C PROV-O lineage tracking, source attribution, audit trails |
| `semantica.change_management` | Version control with SHA-256 checksums, diff, rollback |
| `semantica.llms` | Groq, OpenAI, Anthropic, Gemini, Ollama, DeepSeek, Novita AI, LiteLLM, HuggingFace |
| `semantica.seed` | Foundation graph seeding from CSV, JSON, SQL, API, and RDF sources |
| `semantica.evals` | Evaluation harness: KG quality, extraction F1, pipeline benchmarking, regression tracking |
| `semantica.core` | Orchestration, ConfigManager, LifecycleManager, PluginRegistry, MethodRegistry |
| `semantica.utils` | Logging, validation, progress tracking, hash utilities, nested dict helpers |
## Why Semantica?
**Open Source, MIT.** No vendor lock-in, no paywalled features.
- Full source available on GitHub
- Every line auditable by your security team
- Fork, extend, and self-host with no restrictions
- No telemetry, no usage reporting
**Production Ready.** Built for teams that can't afford surprises.
- 1,000+ passing tests with full regression coverage
- `PipelineValidator` catches configuration errors at startup
- `FailureHandler` with exponential backoff and dead-letter queues
- Ongoing security hardening, with fixes shipped in every release ([CHANGELOG](https://github.com/semantica-agi/semantica/blob/main/CHANGELOG.md))
**Modular by Design.** Import only what you need.
- Use `NERExtractor` without a graph store
- Use `ContextGraph` without vector storage
- Every component independently swappable and testable
- No framework lock-in, and works with any agent stack
+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",
"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:deterministic-e2e": "node --import tsx --test tests/deterministicExplorerRendering.e2e.ts",
"test:plugin-registry": "node --import tsx --test tests/pluginRegistry.temporal.test.mjs"
},
+13 -1
View File
@@ -93,6 +93,18 @@ 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;
@@ -1773,7 +1785,7 @@ function WelcomeScreen({
}
export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>(readInitialWorkspace);
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
@@ -8,8 +8,10 @@ import {
useNodesState,
useEdgesState,
MarkerType,
Handle,
Position,
} from "@xyflow/react";
import type { Connection, Edge, Node } from "@xyflow/react";
import type { Connection, Edge, Node, ReactFlowInstance } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Plus,
@@ -22,10 +24,20 @@ 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>;
@@ -34,12 +46,57 @@ 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",
@@ -79,17 +136,95 @@ interface DraftDiff {
annotation_changes: Record<string, Record<string, any>>;
}
interface RegistryEntry {
uri: string;
name: string;
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 };
}
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: [],
@@ -108,12 +243,18 @@ export function OntologyEditor() {
useEffect(() => {
let cancelled = false;
fetch("/api/ontology/registry")
.then((response) => (response.ok ? response.json() : []))
.then((entries: RegistryEntry[]) => {
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]) => {
if (cancelled) return;
setRegistry(entries);
setOntologyUri((current) => current || entries[0]?.uri || "");
const inferredOntology = inferOntologyUri(entries, requested, explicitOwner);
setOntologyUri((current) => current || inferredOntology || entries[0]?.uri || "");
})
.catch((error) => {
console.error("Failed to load ontology registry:", error);
@@ -123,6 +264,47 @@ 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]
@@ -134,7 +316,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewClass", type: "owl:Class" },
data: { label: "NewClass", type: "owl:Class", entityType: "class" },
};
setNodes((nds) => [...nds, newNode]);
setDraftDiff((prev) => ({
@@ -170,7 +352,7 @@ export function OntologyEditor() {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
data: { label: "NewIndividual", type: "owl:NamedIndividual", entityType: "external" },
};
setNodes((nds) => [...nds, newNode]);
}, [setNodes]);
@@ -190,13 +372,21 @@ export function OntologyEditor() {
}, []);
const autoLayout = useCallback(() => {
const layoutNodes = nodes.map((node, index) => ({
...node,
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
}));
setNodes(layoutNodes);
setNodes(layoutEditorNodes(nodes));
}, [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");
@@ -247,12 +437,11 @@ export function OntologyEditor() {
...prev,
removed_properties: [...prev.removed_properties, target.id],
}));
} else {
} else if (isEditableEntityType(target.data.entityType)) {
setNodes((nds) => nds.filter((n) => n.id !== target.id));
setDraftDiff((prev) => ({
...prev,
removed_classes: [...prev.removed_classes, target.id],
}));
setDraftDiff((prev) => target.data.entityType === "property"
? { ...prev, removed_properties: [...prev.removed_properties, target.id] }
: { ...prev, removed_classes: [...prev.removed_classes, target.id] });
}
setSelectedElement(null);
}
@@ -261,16 +450,21 @@ export function OntologyEditor() {
const renameSelected = useCallback(() => {
const target = showContext?.element ?? selectedElement;
if (target && !("source" in target)) {
if (target && !("source" in target) && isEditableEntityType(target.data.entityType)) {
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) => ({
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
}));
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 } },
});
}
}
setShowContext(null);
@@ -339,11 +533,10 @@ export function OntologyEditor() {
};
const detailPanelStyle: React.CSSProperties = {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
flex: "0 0 320px",
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",
@@ -353,11 +546,24 @@ 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)}
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.
}
}}
style={selectStyle}
>
<option value="">Select ontology...</option>
@@ -398,43 +604,75 @@ export function OntologyEditor() {
</button>
</div>
<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>
<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>
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
{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>
)}
</div>
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</div>
)}
)}
</div>
{selectedElement && (
<div style={detailPanelStyle}>
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
{"source" in selectedElement ? "Property Details" : "Class Details"}
{"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"}
</h3>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
@@ -453,7 +691,9 @@ 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
@@ -463,10 +703,19 @@ export function OntologyEditor() {
);
setDraftDiff((prev) => ({
...prev,
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
...(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 },
},
}),
}));
}}
style={{
@@ -496,3 +745,17 @@ 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,6 +9,32 @@ 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}`;
@@ -31,6 +57,18 @@ 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,6 +38,7 @@ 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
}
@@ -116,4 +117,3 @@ export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps)
</div>
);
}
@@ -0,0 +1,70 @@
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";
}
@@ -0,0 +1,66 @@
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");
});
+160 -6
View File
@@ -234,6 +234,12 @@ 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
@@ -664,6 +670,7 @@ 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)
@@ -680,14 +687,21 @@ 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": f"owl:{prop.get('type', 'Object').title()}Property",
"type": property_type,
"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)
@@ -748,14 +762,38 @@ def _node_source_ontology(node: Dict[str, Any]) -> Optional[str]:
)
def _node_belongs_to_ontology(node: Dict[str, Any], ontology_uri: str) -> bool:
def _node_belongs_to_ontology(
node: Dict[str, Any],
ontology_uri: str,
known_ontology_uris: Optional[set[str]] = None,
) -> bool:
nid = node.get("id", "")
if nid == ontology_uri:
return True
if _node_source_ontology(node) == 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
stem = ontology_uri.rstrip("#/")
return nid.startswith((stem + "#", stem + "/"))
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
def _is_ontology_entity(node: Dict[str, Any]) -> bool:
@@ -1221,7 +1259,8 @@ def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
metadata.setdefault("description", str(obj))
break
if "uri" not in metadata:
synthetic_uri = "uri" not in metadata
if synthetic_uri:
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)
@@ -1268,6 +1307,20 @@ 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
@@ -1768,6 +1821,107 @@ 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,
+24 -1
View File
@@ -37,6 +37,29 @@ 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.
@@ -347,7 +370,7 @@ class PropertyGenerator:
for entity in entities:
for key, value in entity.items():
if key in ["id", "type", "entity_type", "text", "label", "confidence"]:
if key in _CONTROL_FIELDS:
continue
# Infer type
+203 -3
View File
@@ -12,7 +12,11 @@ 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 OntologyEntry # noqa: E402
from semantica.explorer.routes.ontology import ( # noqa: E402
OntologyEntry,
_convert_ontology_to_graph,
_node_belongs_to_ontology,
)
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
@@ -131,6 +135,181 @@ 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",
@@ -696,6 +875,29 @@ 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)
# ---------------------------------------------------------------------------
@@ -789,5 +991,3 @@ 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()
@@ -0,0 +1,94 @@
"""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"