Merge pull request #453 from Hawksight-AI/explorer

feat(explorer): add Semantica Knowledge Explorer UI with full feature…
This commit is contained in:
Mohd Kaif
2026-04-14 11:43:57 +05:30
committed by GitHub
109 changed files with 6522 additions and 1062 deletions
+317 -16
View File
@@ -53,17 +53,219 @@ pip install semantica
---
## Plugins (Claude, Cursor, Codex)
## 🔌 Works With Every AI Tool
Semantica includes a cross-platform plugin bundle under `plugins/` for community use:
Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an **MCP server** (`python -m semantica.mcp_server`) for Windsurf, Cline, Continue, VS Code, and Claude Desktop, and a **REST API** (FastAPI, port 8000) for any other tool.
- 17 domain skills (context graphs, decision intelligence, explainability, reasoning, provenance, ontology, temporal, visualization)
- Specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
- Hook configuration and platform-specific manifests for Claude, Cursor, and Codex
<table>
<tr>
<td align="center" width="12.5%">
<a href="https://claude.com/product/claude-code"><img src="https://github.com/anthropics.png?size=120" alt="Claude Code" width="48" height="48" /></a><br/>
<strong>Claude Code</strong><br/>
<sub>Native plugin · 17 skills · 3 agents · hooks</sub>
</td>
<td align="center" width="12.5%">
<a href="https://cursor.com"><img src="https://www.freelogovectors.net/wp-content/uploads/2025/06/cursor-logo-freelogovectors.net_.png" alt="Cursor" width="48" height="48" /></a><br/>
<strong>Cursor</strong><br/>
<sub>Native plugin · 17 skills · 3 agents</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/openai/codex"><img src="https://github.com/openai.png?size=120" alt="Codex CLI" width="48" height="48" /></a><br/>
<strong>Codex CLI</strong><br/>
<sub>Native plugin · 17 skills · 3 agents</sub>
</td>
<td align="center" width="12.5%">
<a href="https://windsurf.com"><img src="https://exafunction.github.io/public/brand/windsurf-black-symbol.svg" alt="Windsurf" width="48" height="48" /></a><br/>
<strong>Windsurf</strong><br/>
<sub>MCP server + plugin</sub>
</td>
<td align="center" width="12.5%">
<a href="https://claude.ai/download"><img src="https://github.com/anthropics.png?size=120" alt="Claude Desktop" width="48" height="48" /></a><br/>
<strong>Claude Desktop</strong><br/>
<sub>MCP server</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/microsoft/vscode"><img src="https://github.com/microsoft.png?size=120" alt="VS Code" width="48" height="48" /></a><br/>
<strong>VS Code</strong><br/>
<sub>MCP server + plugin</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/features/copilot"><img src="https://github.com/github.png?size=120" alt="GitHub Copilot" width="48" height="48" /></a><br/>
<strong>GitHub Copilot</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/cline/cline"><img src="https://github.com/cline.png?size=120" alt="Cline" width="48" height="48" /></a><br/>
<strong>Cline</strong><br/>
<sub>MCP server + plugin</sub>
</td>
</tr>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/RooCodeInc/Roo-Code"><img src="https://github.com/RooCodeInc.png?size=120" alt="Roo Code" width="48" height="48" /></a><br/>
<strong>Roo Code</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/continuedev/continue"><img src="https://github.com/continuedev.png?size=120" alt="Continue" width="48" height="48" /></a><br/>
<strong>Continue</strong><br/>
<sub>MCP server + plugin</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/block/goose"><img src="https://github.com/block.png?size=120" alt="Goose" width="48" height="48" /></a><br/>
<strong>Goose</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/Kilo-Org/kilocode"><img src="https://github.com/Kilo-Org.png?size=120" alt="Kilo Code" width="48" height="48" /></a><br/>
<strong>Kilo Code</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/Aider-AI/aider"><img src="https://github.com/Aider-AI.png?size=120" alt="Aider" width="48" height="48" /></a><br/>
<strong>Aider</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/aws/amazon-q-developer-cli"><img src="https://github.com/aws.png?size=120" alt="Amazon Q" width="48" height="48" /></a><br/>
<strong>Amazon Q</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<a href="https://zed.dev"><img src="https://github.com/zed-industries.png?size=120" alt="Zed" width="48" height="48" /></a><br/>
<strong>Zed</strong><br/>
<sub>REST API</sub>
</td>
<td align="center" width="12.5%">
<img src="https://img.shields.io/badge/109-endpoints-1f6feb?style=flat-square" alt="REST API" width="48" /><br/>
<strong>Any agent</strong><br/>
<sub>REST API</sub>
</td>
</tr>
</table>
See the community setup guide:
### Plugin Bundles (Claude Code · Cursor · Codex)
- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
| Bundle | Directory | Tools |
|---|---|---|
| Claude Code | [`plugins/.claude-plugin/`](plugins/.claude-plugin/) | 17 skills · 3 agents · hooks |
| Cursor | [`plugins/.cursor-plugin/`](plugins/.cursor-plugin/) | 17 skills · 3 agents · hooks |
| Codex CLI | [`plugins/.codex-plugin/`](plugins/.codex-plugin/) | 17 skills · 3 agents |
| Windsurf | [`plugins/.windsurf-plugin/`](plugins/.windsurf-plugin/) | 17 skills · 3 agents · MCP config |
| Cline | [`plugins/.cline-plugin/`](plugins/.cline-plugin/) | 17 skills · 3 agents · MCP config |
| Continue | [`plugins/.continue-plugin/`](plugins/.continue-plugin/) | 17 skills · 3 agents · MCP config |
| VS Code | [`plugins/.vscode-plugin/`](plugins/.vscode-plugin/) | 17 skills · 3 agents · MCP config |
**17 domain skills:**
| Skill | What it does |
|---|---|
| `extract` | Full semantic extraction pipeline: NER, relations, events, coreference, triplets |
| `ingest` | Data ingestion from files, databases, APIs, streams, and MCP servers |
| `query` | SPARQL, Cypher, keyword search, structured graph patterns |
| `ontology` | Schema management, concepts, relationships, alignments |
| `validate` | Pipeline, extraction, schema, and ontology validation |
| `deduplicate` | Duplicate detection and entity merging with fuzzy matching |
| `embed` | Node2Vec embeddings, similarity scoring, link prediction |
| `reason` | Deductive, abductive, Datalog, SPARQL, and Rete reasoning engines |
| `decision` | Record, query, and analyze decisions; find precedents; causal analysis |
| `causal` | Cause-effect chains, interventions, counterfactuals, causal influence |
| `temporal` | Point-in-time queries, snapshots, timelines, temporal causal analysis |
| `provenance` | Data lineage, source attribution, audit trails |
| `policy` | Policy definition, enforcement, compliance checks, access control |
| `explain` | Decision logic transparency, causal context, audit-ready explanations |
| `export` | Multi-format export: JSON, RDF, Parquet, CSV, GraphML |
| `change` | Graph change tracking, diffs, temporal updates, impact analysis |
| `visualize` | Topology, centrality, communities, paths, embeddings, decision graphs |
**3 specialized agents:**
| Agent | Role |
|---|---|
| `kg-assistant` | General-purpose KG-aware assistant — knows all APIs and method signatures |
| `decision-advisor` | Decision intelligence specialist: causal reasoning, precedents, policy violations |
| `explainability` | Reasoning transparency specialist — generates audit-ready explanation reports |
**Hooks** (`plugins/hooks/hooks.json`) — `PreToolUse` / `PostToolUse` matchers for syntax validation and automated warnings.
→ [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
### MCP Server (expose Semantica to any MCP-aware tool)
Semantica ships a full **MCP server** (`semantica/mcp_server.py`) — run it once and any MCP-compatible tool connects automatically:
```bash
python -m semantica.mcp_server
```
Add to your tool's config (Claude Desktop, Windsurf, Cline, Continue, VS Code, Roo Code):
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
```
**12 tools exposed:** `extract_entities`, `extract_relations`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, `get_graph_summary`
**3 resources:** `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`
See [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md) for per-tool config snippets.
### MCP Client (Ingest from MCP Servers)
Semantica also includes an **MCP client** (`semantica/ingest/mcp_client.py`) that lets you pull data from any Python/FastMCP server into a knowledge graph:
```python
from semantica.ingest import MCPClient
client = MCPClient("http://your-mcp-server:8080")
resources = client.list_resources() # discover available resources
data = client.read_resource("resource://your-data")
```
Supported connection schemes: `http://`, `https://`, `mcp://`, `sse://` · JSON-RPC · auth support · dynamic capability discovery.
---
## 🖥️ Semantica Knowledge Explorer
A real-time visual interface for exploring every dimension of your knowledge graph — built into the repo under [`explorer/`](explorer/).
| Workspace | What you can do |
|---|---|
| **Knowledge Graph** | Pan, zoom, and inspect a live Sigma.js graph canvas with ForceAtlas2 layout |
| **Timeline** | Scrub through temporal events and watch the graph evolve |
| **Decisions** | Browse the causal chain behind every recorded decision with outcome badges |
| **Registry** | Live audit log of every graph mutation — add-node, add-edge, merge, delete |
| **Entity Resolution** | Review and merge duplicate entities detected by the deduplication engine |
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
| **Ontology** | SKOS/OWL vocabulary hierarchy and auto-generated schema summary |
### Run locally
```bash
# 1. Start the Semantica backend (port 8000)
python -m semantica.server
# 2. In a second terminal
cd explorer
npm install
npm run dev
```
Open **http://localhost:5173** — the Explorer connects automatically. All `/api` and `/ws` traffic is proxied to `127.0.0.1:8000` by Vite, so no CORS configuration is needed.
> **Requirements:** Node 18+ · Python 3.8+ · npm 9+
For the full setup guide, troubleshooting, and production build instructions see [`explorer/README.md`](explorer/README.md).
---
@@ -347,6 +549,7 @@ Semantic memory with hybrid search and metadata filtering.
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
| [`explorer/`](explorer/) | **Semantica Knowledge Explorer** — React 19 + Sigma.js UI: graph canvas, decision viewer, causal chains, entity resolution, ontology browser, and registry audit log |
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
@@ -643,16 +846,60 @@ if result.valid:
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
---
## 🔌 Integrations
### AI Coding Tools & IDEs
Start the Semantica server (`python -m semantica.server`, port 8000) and point any tool at `http://localhost:8000`. Tools marked **Native plugin** also get 17 skills, 3 agents, and hook config out of the box.
| Tool | Connection | Notes |
|---|---|---|
| [Claude Code](https://claude.com/product/claude-code) | **Native plugin** | `plugins/.claude-plugin/` — 17 skills, 3 agents, `hooks.json` |
| [Cursor](https://cursor.com) | **Native plugin** | `plugins/.cursor-plugin/` — same 17 skills + 3 agents |
| [Codex CLI](https://github.com/openai/codex) | **Native plugin** | `plugins/.codex-plugin/` — same 17 skills + 3 agents |
| [Windsurf](https://windsurf.com) | **MCP server** + plugin | `plugins/.windsurf-plugin/` · add `python -m semantica.mcp_server` to `~/.codeium/windsurf/mcp_config.json` |
| [Claude Desktop](https://claude.ai/download) | **MCP server** | Add `python -m semantica.mcp_server` to `claude_desktop_config.json` |
| [VS Code](https://github.com/microsoft/vscode) | **MCP server** + plugin | `plugins/.vscode-plugin/` · add to `settings.json` under `mcp.servers` |
| [GitHub Copilot](https://github.com/features/copilot) | REST API | Use via Copilot Chat custom tools |
| [Cline](https://github.com/cline/cline) | **MCP server** + plugin | `plugins/.cline-plugin/` · add server in Cline MCP settings panel |
| [Roo Code](https://github.com/RooCodeInc/Roo-Code) | **MCP server** | Add `python -m semantica.mcp_server` in Roo Code MCP settings |
| [Continue](https://github.com/continuedev/continue) | **MCP server** + plugin | `plugins/.continue-plugin/` · add to `~/.continue/config.json` under `mcpServers` |
| [Goose](https://github.com/block/goose) | REST API | Add to Goose toolset config |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | REST API | Add as custom REST tool |
| [Aider](https://github.com/Aider-AI/aider) | REST API | Pass context from the API into prompts |
| [Amazon Q Developer](https://github.com/aws/amazon-q-developer-cli) | REST API | Use via Q Developer custom tools |
| [Zed](https://zed.dev) | REST API | Integrate via Zed assistant context |
| Any agent | REST API | 109 endpoints — drop-in with any HTTP client |
### REST API Server
Run `python -m semantica.server` (or `python -m semantica`) — FastAPI on port 8000 with the following route groups:
| Route group | Module | Endpoints |
|---|---|---|
| `/api/graph` | `routes/graph.py` | Nodes, edges, traversal, graph topology |
| `/api/analytics` | `routes/analytics.py` | Centrality, communities, metrics |
| `/api/decisions` | `routes/decisions.py` | Decision CRUD, precedent search, causal chains |
| `/api/temporal` | `routes/temporal.py` | Point-in-time queries, snapshots, timelines |
| `/api/export` | `routes/export_import.py` | Import/export in RDF, Parquet, JSON, CSV, GraphML |
| `/api/annotations` | `routes/annotations.py` | Entity and edge annotation |
| `/api/enrich` | `routes/enrich.py` | Graph enrichment — embeddings, vectors, metadata |
| `/api/sparql` | `routes/sparql.py` | SPARQL query execution |
| `/api/provenance` | `routes/provenance.py` | Data lineage and audit trails |
| `/api/vocabulary` | `routes/vocabulary.py` | Ontology, SKOS concepts, schema definitions |
| `/ws` | `ws.py` | WebSocket — real-time graph mutation events |
| `/health` | `server.py` | Health check |
### Graph Databases
- **AWS Neptune** — Amazon Neptune with IAM authentication
- **Neo4j** — Cypher queries via `semantica.graph_store`
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
- **Apache AGE** — PostgreSQL + openCypher via SQL
- **FalkorDB** — native support for decision queries and causal analysis
- **AWS Neptune** — Amazon Neptune with IAM authentication
### Vector Databases
- **FAISS** — built-in, zero extra dependencies
@@ -668,22 +915,76 @@ if result.valid:
- **Databases** — SQL via `DBIngestor`
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
- **Email** — inbox ingestion via `EmailIngestor`
- **Repositories** — Git repo ingestion for code graph construction
### LLM Providers
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
- **Ollama** — local models including remote server support
### Agentic Frameworks
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
Semantica complements — not replaces — every major agentic framework. Use it as the accountability layer on top.
<table>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="40" height="40" /></a><br/>
<strong>Agno</strong><br/>
<sub>First-class · <code>pip install semantica[agno]</code></sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="40" height="40" /></a><br/>
<strong>LangChain</strong><br/>
<sub>Context layer</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="40" height="40" /></a><br/>
<strong>LangGraph</strong><br/>
<sub>Stateful agent graph</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="40" height="40" /></a><br/>
<strong>LlamaIndex</strong><br/>
<sub>GraphRAG retriever</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="40" height="40" /></a><br/>
<strong>AutoGen</strong><br/>
<sub>Shared context graph</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="40" height="40" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>Decision + provenance</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="40" height="40" /></a><br/>
<strong>OpenAI Agents</strong><br/>
<sub>Context + KG tools</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="40" height="40" /></a><br/>
<strong>Google ADK</strong><br/>
<sub>Context layer</sub>
</td>
</tr>
</table>
> **Agno — First-Class Integration** · `pip install semantica[agno]`
>
> Five ready-to-use Agno components:
> - `AgnoContextStore` — graph-backed agent memory
> - `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base
> - `AgnoDecisionKit` — 6 decision-intelligence tools
> - `AgnoKGToolkit` — 7 KG pipeline tools
> - `AgnoSharedContext` — shared context graph for multi-agent teams
> Five integration modules live in [`integrations/agno/`](integrations/agno/):
>
> | Module | Class | What it does |
> |---|---|---|
> | `context_store.py` | `AgnoContextStore` | Graph-backed agent memory — store and retrieve structured context |
> | `knowledge_graph.py` | `AgnoKnowledgeGraph` | Implements Agno's `AgentKnowledge` protocol; full extraction pipeline |
> | `decision_kit.py` | `AgnoDecisionKit` | 6 decision-intelligence tools for Agno agents |
> | `kg_toolkit.py` | `AgnoKGToolkit` | 7 KG pipeline tools (build, query, enrich, export) |
> | `shared_context.py` | `AgnoSharedContext` | Shared context graph for multi-agent team coordination |
---
+197
View File
@@ -0,0 +1,197 @@
# Semantica Knowledge Explorer
A real-time visual interface for exploring knowledge graphs, decision intelligence, entity resolution, ontologies, and graph analytics built on top of the [Semantica](https://github.com/Hawksight-AI/semantica) library.
---
## Requirements
| Dependency | Minimum Version |
|---|---|
| Node.js | 18.x or higher (20.x recommended) |
| npm | 9.x or higher |
| Python | 3.8+ |
| Semantica backend | running on `http://127.0.0.1:8000` |
Check your versions:
```bash
node --version
npm --version
python --version
```
---
## Quick Start (Local Development)
### 1. Clone the repository
```bash
git clone https://github.com/Hawksight-AI/semantica.git
cd semantica
```
### 2. Install the Semantica Python package
```bash
pip install semantica
```
Or install from source if you have the repo:
```bash
pip install -e .
```
### 3. Start the Semantica backend
The Explorer proxies all `/api` and `/ws` requests to `http://127.0.0.1:8000`. The backend must be running before you open the UI.
```bash
# From the repo root
python -m semantica.server
```
The backend starts on port **8000** by default. Keep this terminal open.
### 4. Install frontend dependencies
Open a second terminal:
```bash
cd explorer
npm install
```
> **Note:** This project uses Vite 5 and requires **Node 18+**. If you are on Node 16 or earlier, upgrade first.
### 5. Start the dev server
```bash
npm run dev
```
Vite starts on **http://localhost:5173** by default. Open that URL in your browser.
---
## What you should see
The Explorer opens with a persistent left sidebar and six workspace tabs:
| Tab | What it shows |
|---|---|
| **Knowledge Graph** | Interactive Sigma.js canvas — nodes, edges, zoom, ForceAtlas2 layout |
| **Timeline** | Temporal event scrubber over the graph |
| **Decisions** | Causal chain viewer with outcome badges and decision filter |
| **Registry** | Live audit log of every graph mutation (add-node, add-edge, etc.) |
| **Entity Resolution** | Duplicate detection and entity merge workflow |
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
| **Ontology** | SKOS/OWL vocabulary hierarchy and schema summary |
---
## Project structure
```
explorer/
├── src/
│ ├── App.tsx # Root layout, tab routing, workspace wiring
│ ├── index.css # Global resets, fonts, keyframe animations
│ ├── store/
│ │ └── registryStore.ts # Pub/sub audit registry (no external state lib)
│ └── workspaces/
│ ├── GraphWorkspace/ # Sigma.js graph canvas + inspector panel
│ ├── DecisionWorkspace/ # Causal flow diagram + decision list
│ ├── TimelineWorkspace/ # vis-timeline temporal scrubber
│ ├── ManageWorkspace/ # Registry, KG Overview, Ontology tabs
│ └── EnrichWorkspace/ # Entity resolution tab
├── index.html
├── vite.config.ts # Dev proxy → 127.0.0.1:8000, build → ../semantica/static
└── package.json
```
---
## Available scripts
Run these from inside the `explorer/` directory:
```bash
# Start the dev server with hot module replacement
npm run dev
# Type-check and build a production bundle into ../semantica/static
npm run build
# Preview the production build locally
npm run preview
# Run ESLint over all source files
npm run lint
# Run the graph store multi-edge unit tests
npm run test:graph-store
```
---
## API & WebSocket proxy
During development, Vite forwards requests automatically — no CORS configuration needed:
| Pattern | Forwarded to |
|---|---|
| `/api/*` | `http://127.0.0.1:8000/api/*` |
| `/ws` | `ws://127.0.0.1:8000/ws` |
If you run the backend on a different port, update `server.proxy` in [vite.config.ts](vite.config.ts).
---
## Production build
```bash
cd explorer
npm run build
```
The compiled assets are written to `../semantica/static/`. The Semantica Python server serves this folder automatically at its root URL — no separate web server needed.
---
## Troubleshooting
**Blank graph / no data loads**
- Make sure the Semantica backend is running (`python -m semantica.server`) before opening the UI.
- Check the browser console for failed `/api/graph` requests — the proxy target may need updating in `vite.config.ts`.
**`npm install` fails or hangs**
- Ensure you are using **Node 18 or 20**. Node 16 and Vite 5 are incompatible.
- Delete `node_modules/` and `package-lock.json`, then re-run `npm install`.
**Port 5173 already in use**
- Vite will automatically try the next available port and print it in the terminal. Use that URL instead.
**WebSocket not connecting (real-time mutations not appearing)**
- Confirm the backend exposes a `/ws` WebSocket endpoint.
- Check browser DevTools → Network → WS tab for the connection status.
---
## Tech stack
- **React 19** + TypeScript (strict `noUnusedLocals`)
- **Vite 5** with `babel-plugin-react-compiler`
- **Sigma.js 3** + **Graphology** — graph rendering and in-memory graph store
- **ForceAtlas2** — physics-based layout worker
- **@tanstack/react-query** — data fetching for ontology and vocab tabs
- **vis-timeline** — temporal event visualization
- **lucide-react** — icon set
---
## Contributing
See the root [CONTRIBUTING.md](../CONTRIBUTING.md) and open issues on the main [Semantica repository](https://github.com/Hawksight-AI/semantica).
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semantica-explorer</title>
<title>Semantica Knowledge Explorer</title>
</head>
<body>
<div id="root"></div>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"name": "semantica-explorer",
"name": "semantica-knowledge-explorer",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -33,12 +33,11 @@
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^9.39.4",
"@rolldown/plugin-babel": "^0.2.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^4.3.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -46,6 +45,6 @@
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
"vite": "^5.4.0"
}
}

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -10,11 +10,16 @@ const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageD
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab').then((module) => ({ default: module.RegistryTab })));
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
type NavItem = {
id: WorkspaceId;
@@ -26,7 +31,7 @@ type NavItem = {
const queryClient = new QueryClient();
const navItems: NavItem[] = [
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
{ id: 'explore', label: 'Knowledge Explorer', hint: 'Graph and vocabulary browsing', icon: Database },
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
@@ -275,19 +280,21 @@ function WorkspaceShell({
subtitle,
tabs,
compact = false,
kicker = 'Workspace',
children,
}: {
title: string;
subtitle?: string;
tabs?: ReactNode;
compact?: boolean;
kicker?: string;
children: ReactNode;
}) {
return (
<section className="workspace-shell">
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
<div className="workspace-header-main">
<div className="workspace-kicker">Workspace</div>
<div className="workspace-kicker">{kicker}</div>
<div className="workspace-title-block">
<h1 className="workspace-title">{title}</h1>
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
@@ -309,6 +316,7 @@ export default function App() {
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const renderWorkspace = () => {
if (activeWorkspace === 'explore') {
@@ -316,6 +324,7 @@ export default function App() {
<WorkspaceShell
title="Explore"
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
compact
tabs={
<>
@@ -340,6 +349,7 @@ export default function App() {
<WorkspaceShell
title="Analyze"
subtitle="Query the active graph and test inference rules."
kicker={analyzeView === 'reasoning' ? 'Reasoning Engine' : 'SPARQL Query'}
tabs={
<>
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
@@ -363,6 +373,7 @@ export default function App() {
<WorkspaceShell
title="Decisions"
subtitle="Inspect decision chains, causal context, and precedent matches."
kicker="Decision Intelligence"
>
<Suspense fallback={<WorkspaceFallback />}>
<DecisionWorkspace />
@@ -375,7 +386,8 @@ export default function App() {
return (
<WorkspaceShell
title="Enrich"
subtitle="Import, export, and reconcile graph entities."
subtitle="Import, export, reconcile, and audit graph entities."
kicker="Knowledge Audit"
tabs={
<>
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
@@ -384,11 +396,20 @@ export default function App() {
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
Diff and Merge
</button>
<button className="workspace-tab" data-active={enrichView === 'resolve'} onClick={() => setEnrichView('resolve')}>
Entity Resolution
</button>
<button className="workspace-tab" data-active={enrichView === 'registry'} onClick={() => setEnrichView('registry')}>
Registry
</button>
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
{enrichView === 'import' ? <ImportExportWorkspace /> :
enrichView === 'merge' ? <DiffMergeWorkspace /> :
enrichView === 'resolve' ? <EntityResolutionTab /> :
<RegistryTab />}
</Suspense>
</WorkspaceShell>
);
@@ -397,10 +418,29 @@ export default function App() {
return (
<WorkspaceShell
title="Manage"
subtitle="Review provenance, lineage, and governance context."
subtitle="Review provenance, lineage, ontology, and governance context."
kicker="Graph Governance"
tabs={
<>
<button className="workspace-tab" data-active={manageView === 'lineage'} onClick={() => setManageView('lineage')}>
PROV-O Lineage
</button>
<button className="workspace-tab" data-active={manageView === 'kg-overview'} onClick={() => setManageView('kg-overview')}>
KG Overview
</button>
<button className="workspace-tab" data-active={manageView === 'ontology'} onClick={() => setManageView('ontology')}>
Ontology Summary
</button>
</>
}
>
<Suspense fallback={<WorkspaceFallback />}>
<LineageDiagram />
{manageView === 'lineage' ? <LineageDiagram /> :
manageView === 'kg-overview' ? <KGOverviewTab /> :
<OntologySummaryTab onOpenVocabularyBrowser={() => {
setActiveWorkspace('explore');
setExploreView('vocabulary');
}} />}
</Suspense>
</WorkspaceShell>
);
@@ -411,7 +451,7 @@ export default function App() {
<style>{shellStyles}</style>
<div className="app-shell">
<aside className="app-rail">
<div className="brand-pill">SEM</div>
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
{navItems.map(({ id, label, hint, icon: Icon }) => (
<button
key={id}

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -63,3 +63,9 @@ code, pre, .mono {
.animate-spin {
animation: spin 1s linear infinite;
}
/* Skeleton pulse animation for loading placeholders */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 0.85; }
}
+77
View File
@@ -0,0 +1,77 @@
/**
* src/store/registryStore.ts
*
* Lightweight client-side audit log for all KG / Ontology mutations.
* No backend required — events are dispatched by each workspace after
* a successful API call or WebSocket mutation.
*
* Any component can call logEvent() from anywhere (including non-React code).
* React components subscribe via the useRegistry() hook.
*/
import { useState, useEffect } from "react";
export type RegistryEntryOp =
| "import"
| "export"
| "merge"
| "add-node"
| "add-edge"
| "delete"
| "infer"
| "vocab-import";
export interface RegistryEntry {
id: string;
op: RegistryEntryOp;
timestamp: Date;
summary: string;
detail?: Record<string, unknown>;
}
type Listener = (entries: readonly RegistryEntry[]) => void;
let _entries: RegistryEntry[] = [];
const _listeners = new Set<Listener>();
const MAX_ENTRIES = 500;
function _notify(): void {
_listeners.forEach((fn) => fn(_entries));
}
export function logEvent(
op: RegistryEntryOp,
summary: string,
detail?: Record<string, unknown>,
): void {
const entry: RegistryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
op,
timestamp: new Date(),
summary,
detail,
};
_entries = [entry, ..._entries].slice(0, MAX_ENTRIES);
_notify();
}
export function clearRegistry(): void {
_entries = [];
_notify();
}
export function getRegistryEntries(): readonly RegistryEntry[] {
return _entries;
}
export function useRegistry(): readonly RegistryEntry[] {
const [snapshot, setSnapshot] = useState<readonly RegistryEntry[]>(_entries);
useEffect(() => {
// Sync any events that arrived between render and subscribe
setSnapshot(_entries);
_listeners.add(setSnapshot);
return () => {
_listeners.delete(setSnapshot);
};
}, []);
return snapshot;
}
@@ -0,0 +1,407 @@
/**
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
*/
import { useState, useEffect, useMemo } from "react";
import { Scale, Search } from "lucide-react";
const THEME_CSS = `
.glass-panel {
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border: 1px solid rgba(88,166,255,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
}
@keyframes skeleton-shimmer {
0% { opacity: 0.45; }
50% { opacity: 0.85; }
100% { opacity: 0.45; }
}
.skeleton-item {
border-radius: 8px;
background: rgba(255,255,255,0.05);
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
`;
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
const lower = (outcome ?? "").toLowerCase();
if (lower.includes("approv") || lower.includes("accept"))
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
}
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
const style = outcomeStyle(outcome);
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 999,
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: style.color,
background: style.bg,
border: `1px solid ${style.border}`,
}}
>
{outcome || "unknown"}
</span>
);
}
function SkeletonList() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton-item" style={{ height: 62 }} />
))}
</div>
);
}
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
interface ChainStep {
id: string;
relationship: string;
content?: string;
type?: string;
[key: string]: unknown;
}
function RelationshipPill({ label }: { label: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
{/* Connector line top */}
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
{/* Pill */}
<div
style={{
padding: "3px 10px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "#79c0ff",
background: "rgba(88,166,255,0.1)",
border: "1px solid rgba(88,166,255,0.22)",
whiteSpace: "nowrap",
maxWidth: 260,
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={label}
>
{label}
</div>
{/* Connector line bottom + arrow */}
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
</div>
);
}
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
const color = COLORS[index % COLORS.length];
return (
<div
style={{
position: "relative",
padding: "14px 16px",
borderRadius: 12,
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
border: `1px solid ${color}33`,
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
borderLeft: `3px solid ${color}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<span
style={{
width: 8, height: 8, borderRadius: "50%",
background: color,
boxShadow: `0 0 8px ${color}`,
flexShrink: 0,
}}
/>
{step.type ? (
<span
style={{
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
textTransform: "uppercase", color,
}}
>
{step.type}
</span>
) : null}
</div>
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
{step.content || step.id}
</div>
{step.id && step.id !== step.content ? (
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
) : null}
</div>
);
}
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
if (loading) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{[1, 2, 3].map((i) => (
<div key={i} className="skeleton-item" style={{ height: 68 }} />
))}
</div>
);
}
if (chain.length === 0) {
return (
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
No causal chain steps found for this decision.
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
{chain.map((step, index) => (
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
<ChainNodeCard step={step} index={index} />
{index < chain.length - 1 ? (
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
) : null}
</div>
))}
</div>
);
}
/* ─── Main Workspace ──────────────────────────────────────────────── */
export function DecisionWorkspace() {
const [decisions, setDecisions] = useState<any[]>([]);
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
const [chain, setChain] = useState<ChainStep[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(true);
const [filterQuery, setFilterQuery] = useState("");
useEffect(() => {
const controller = new AbortController();
setListLoading(true);
fetch("/api/decisions", { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`);
return res.json();
})
.then((data) => {
setDecisions(data);
if (data.length > 0) void handleSelectDecision(data[0]);
})
.catch((err) => { if (err.name !== "AbortError") console.error(err); })
.finally(() => setListLoading(false));
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredDecisions = useMemo(() => {
if (!filterQuery.trim()) return decisions;
const q = filterQuery.toLowerCase();
return decisions.filter(
(d) =>
String(d.decision_id ?? "").toLowerCase().includes(q) ||
String(d.category ?? "").toLowerCase().includes(q) ||
String(d.outcome ?? "").toLowerCase().includes(q),
);
}, [decisions, filterQuery]);
const handleSelectDecision = async (d: any) => {
setSelectedDecision(d);
setLoading(true);
setChain([]);
const controller = new AbortController();
try {
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal });
if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`);
const data = await res.json();
setChain(data.chain || []);
} catch (e) {
if ((e as DOMException).name !== "AbortError") console.error(e);
} finally {
setLoading(false);
}
return () => controller.abort();
};
return (
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
<style>{THEME_CSS}</style>
{/* Left Column — Decision List */}
<div
className="glass-panel"
style={{
width: 300,
display: "flex",
flexDirection: "column",
borderRadius: 0,
border: "none",
borderRight: "1px solid rgba(88,166,255,0.16)",
}}
>
{/* List header */}
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<Scale size={16} color="#4aa3ff" />
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
{decisions.length > 0 ? (
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
) : null}
</div>
{/* Filter input */}
<div style={{ position: "relative" }}>
<Search
size={13}
color="#8b949e"
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
/>
<input
type="text"
placeholder="Filter decisions…"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
style={filterInputStyle}
/>
</div>
</div>
{/* Decision list */}
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
{listLoading ? (
<SkeletonList />
) : filteredDecisions.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{filteredDecisions.map((d) => {
const isActive = selectedDecision?.decision_id === d.decision_id;
return (
<button
key={d.decision_id}
onClick={() => void handleSelectDecision(d)}
style={{
textAlign: "left",
padding: "10px 12px",
borderRadius: 10,
cursor: "pointer",
background: isActive
? "rgba(74,163,255,0.15)"
: "rgba(255,255,255,0.02)",
border: isActive
? "1px solid rgba(74,163,255,0.32)"
: "1px solid rgba(255,255,255,0.06)",
color: isActive ? "#ffffff" : "#c6d4e3",
transition: "all 160ms ease",
}}
>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
{d.category ? (
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
) : null}
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* Right Column — Decision Detail */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* Radial accent */}
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
{selectedDecision ? (
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
{/* Decision header */}
<div style={{ marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
Decision ID
</div>
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
{selectedDecision.decision_id}
</h1>
</div>
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
</div>
{selectedDecision.category ? (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
{selectedDecision.category}
</div>
) : null}
</div>
{/* Causal Chain */}
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
Causal Chain
</h3>
{chain.length > 0 && !loading ? (
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
{chain.length} step{chain.length !== 1 ? "s" : ""}
</span>
) : null}
</div>
<CausalFlowDiagram chain={chain} loading={loading} />
</div>
</div>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
Select a decision to inspect its causal chain.
</div>
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const filterInputStyle: React.CSSProperties = {
width: "100%",
padding: "7px 10px 7px 30px",
background: "rgba(0,0,0,0.25)",
border: "1px solid rgba(88,166,255,0.16)",
borderRadius: 8,
color: "#c6d4e3",
fontSize: 12,
outline: "none",
boxSizing: "border-box",
};
@@ -2,6 +2,7 @@
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
*/
import { useState } from "react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
@@ -29,6 +30,11 @@ export function DiffMergeWorkspace() {
const data = await res.json();
if (data.merged_into) {
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
logEvent("merge", `Merged ${duplicateId}${data.merged_into} · ${data.edges_updated} edges redirected`, {
primary: data.merged_into,
duplicate: duplicateId,
edgesUpdated: data.edges_updated,
});
} else {
setMsg("Merge failed...");
}
@@ -0,0 +1,450 @@
/**
* src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
*
* Entity Resolution — run duplicate detection, review flagged pairs,
* perform one-click merges, and view merge history from the Registry.
*/
import { useState, useCallback } from "react";
import { ScanSearch, GitMerge, X, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { logEvent, useRegistry } from "../../store/registryStore";
interface DedupPair {
a: { id: string; label: string; type: string };
b: { id: string; label: string; type: string };
score: number;
dismissed?: boolean;
}
interface RawDuplicateItem {
entity_a?: string | Record<string, unknown>;
entity_b?: string | Record<string, unknown>;
similarity?: number;
score?: number;
[key: string]: unknown;
}
function extractId(entity: string | Record<string, unknown> | undefined): string {
if (!entity) return "";
if (typeof entity === "string") return entity;
return String(entity.id ?? entity.text ?? JSON.stringify(entity));
}
function extractLabel(entity: string | Record<string, unknown> | undefined): string {
if (!entity) return "";
if (typeof entity === "string") return entity;
return String(entity.text ?? entity.label ?? entity.content ?? entity.id ?? "");
}
function extractType(entity: string | Record<string, unknown> | undefined): string {
if (!entity || typeof entity === "string") return "entity";
return String(entity.type ?? "entity");
}
function parseDuplicates(raw: RawDuplicateItem[]): DedupPair[] {
return raw.map((item) => ({
a: {
id: extractId(item.entity_a as string | Record<string, unknown>),
label: extractLabel(item.entity_a as string | Record<string, unknown>),
type: extractType(item.entity_a as string | Record<string, unknown>),
},
b: {
id: extractId(item.entity_b as string | Record<string, unknown>),
label: extractLabel(item.entity_b as string | Record<string, unknown>),
type: extractType(item.entity_b as string | Record<string, unknown>),
},
score: Number(item.similarity ?? item.score ?? 0),
}));
}
function ScoreBar({ score }: { score: number }) {
const pct = Math.min(100, Math.round(score * 100));
const color = score >= 0.9 ? "#ff7b72" : score >= 0.75 ? "#f2b66d" : "#4cc38a";
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ flex: 1, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 999, background: color, transition: "width 300ms ease" }} />
</div>
<span style={{ fontSize: 11, fontWeight: 700, color, minWidth: 34, textAlign: "right" }}>
{pct}%
</span>
</div>
);
}
function PairRow({
pair,
onMerge,
onDismiss,
}: {
pair: DedupPair;
onMerge: (primaryId: string, duplicateId: string) => Promise<void>;
onDismiss: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [merging, setMerging] = useState(false);
const handleMerge = async () => {
setMerging(true);
await onMerge(pair.a.id, pair.b.id);
setMerging(false);
};
return (
<div style={pairCardStyle}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
{/* Expand */}
<button onClick={() => setExpanded((v) => !v)} style={iconBtnStyle}>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
{/* Entity Labels */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={entityChipStyle}>{pair.a.label || pair.a.id}</span>
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}></span>
<span style={entityChipStyle}>{pair.b.label || pair.b.id}</span>
</div>
<div style={{ marginTop: 8 }}>
<ScoreBar score={pair.score} />
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<button
onClick={() => void handleMerge()}
disabled={merging}
style={{
...actionBtnStyle,
background: "rgba(76,195,138,0.12)",
border: "1px solid rgba(76,195,138,0.28)",
color: "#4cc38a",
}}
>
{merging ? <Loader2 size={12} className="animate-spin" /> : <GitMerge size={12} />}
<span>Merge</span>
</button>
<button onClick={onDismiss} style={iconBtnStyle} title="Dismiss">
<X size={13} />
</button>
</div>
</div>
{/* Expanded diff */}
{expanded ? (
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{[
{ label: "Primary (keep)", entity: pair.a, accentColor: "#4aa3ff" },
{ label: "Duplicate (remove)", entity: pair.b, accentColor: "#ff7b72" },
].map(({ label, entity, accentColor }) => (
<div key={entity.id} style={{ ...diffCardStyle, borderColor: `${accentColor}33` }}>
<div style={{ color: accentColor, fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
{label}
</div>
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600 }}>{entity.label || entity.id}</div>
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>{entity.type}</div>
<div style={{ color: "#6a7f97", fontSize: 10, marginTop: 4, fontFamily: "monospace" }}>{entity.id}</div>
</div>
))}
</div>
) : null}
</div>
);
}
export function EntityResolutionTab() {
const [threshold, setThreshold] = useState(0.82);
const [scanning, setScanning] = useState(false);
const [pairs, setPairs] = useState<DedupPair[]>([]);
const [scanError, setScanError] = useState("");
const registryEntries = useRegistry();
const mergeHistory = registryEntries.filter((e) => e.op === "merge");
const handleScan = useCallback(async () => {
setScanning(true);
setScanError("");
try {
const res = await fetch("/api/enrich/dedup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threshold }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as Record<string, string>).detail ?? `Scan failed (${res.status})`);
}
const data = await res.json();
const rawDuplicates: RawDuplicateItem[] = Array.isArray(data.duplicates)
? (data.duplicates as RawDuplicateItem[])
: [];
const parsed = parseDuplicates(rawDuplicates);
setPairs(parsed);
logEvent("import", `Dedup scan found ${parsed.length} flagged pair${parsed.length !== 1 ? "s" : ""} (threshold ${threshold.toFixed(2)})`, {
threshold,
flagged: parsed.length,
});
} catch (err) {
setScanError(err instanceof Error ? err.message : "Scan failed");
} finally {
setScanning(false);
}
}, [threshold]);
const handleMerge = useCallback(async (primaryId: string, duplicateId: string) => {
try {
const res = await fetch("/api/enrich/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
});
if (!res.ok) throw new Error(`Merge failed (${res.status})`);
const data = await res.json();
logEvent("merge", `Merged ${duplicateId}${primaryId} · ${data.edges_updated ?? 0} edges redirected`, {
primary: primaryId,
duplicate: duplicateId,
edgesUpdated: data.edges_updated,
});
setPairs((prev) => prev.filter((p) => !(p.a.id === primaryId && p.b.id === duplicateId)));
} catch (err) {
console.error("[EntityResolution] merge failed", err);
}
}, []);
const handleDismiss = useCallback((index: number) => {
setPairs((prev) => prev.filter((_, i) => i !== index));
}, []);
return (
<div style={shellStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ScanSearch size={18} color="#f2b66d" />
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Entity Resolution</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>Detect and merge duplicate entities in the knowledge graph</div>
</div>
</div>
</div>
{/* Scan controls */}
<div style={controlsCardStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
<div style={{ flex: 1, minWidth: 240 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<label style={{ color: "#c6d4e3", fontSize: 12, fontWeight: 600 }}>Similarity Threshold</label>
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>{threshold.toFixed(2)}</span>
</div>
<input
type="range"
min={0.5}
max={0.99}
step={0.01}
value={threshold}
onChange={(e) => setThreshold(parseFloat(e.target.value))}
style={{ width: "100%", accentColor: "#f2b66d", cursor: "pointer" }}
/>
<div style={{ display: "flex", justifyContent: "space-between", color: "#6a7f97", fontSize: 10, marginTop: 2 }}>
<span>More results (0.50)</span>
<span>Fewer, higher confidence (0.99)</span>
</div>
</div>
<button
onClick={() => void handleScan()}
disabled={scanning}
style={scanBtnStyle}
>
{scanning ? <Loader2 size={14} className="animate-spin" /> : <ScanSearch size={14} />}
<span>{scanning ? "Scanning…" : "Run Dedup Scan"}</span>
</button>
</div>
{scanError ? (
<div style={{ color: "#ff7b72", fontSize: 12, marginTop: 8 }}>{scanError}</div>
) : null}
</div>
<div style={{ flex: 1, overflow: "hidden", display: "flex", gap: 0 }}>
{/* Flagged pairs */}
<div style={{ flex: 1, overflowY: "auto", padding: "16px 24px", display: "flex", flexDirection: "column", gap: 10 }}>
{pairs.length > 0 ? (
<>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
<div style={{ color: "#8b949e", fontSize: 12, fontWeight: 600 }}>
{pairs.length} flagged pair{pairs.length !== 1 ? "s" : ""}
</div>
<button onClick={() => setPairs([])} style={clearAllBtnStyle}>Clear all</button>
</div>
{pairs.map((pair, index) => (
<PairRow
key={`${pair.a.id}:${pair.b.id}`}
pair={pair}
onMerge={handleMerge}
onDismiss={() => handleDismiss(index)}
/>
))}
</>
) : (
<div style={emptyStateStyle}>
<ScanSearch size={36} color="rgba(242,182,109,0.15)" />
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
No flagged pairs
</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 280 }}>
Set a similarity threshold and run a dedup scan to detect potential duplicates.
</div>
</div>
)}
</div>
{/* Merge history sidebar */}
{mergeHistory.length > 0 ? (
<div style={historyPanelStyle}>
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 10 }}>
Merge History
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{mergeHistory.map((entry) => (
<div key={entry.id} style={historyRowStyle}>
<GitMerge size={11} color="#f2b66d" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#c6d4e3", fontSize: 11, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{entry.summary}
</div>
<div style={{ color: "#6a7f97", fontSize: 10 }}>
{entry.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0d1117",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
padding: "20px 24px 16px",
borderBottom: "1px solid rgba(88,166,255,0.1)",
flexShrink: 0,
};
const controlsCardStyle: React.CSSProperties = {
margin: "16px 24px",
padding: "16px 20px",
borderRadius: 14,
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6))",
border: "1px solid rgba(242,182,109,0.18)",
flexShrink: 0,
};
const scanBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "10px 18px",
borderRadius: 10,
background: "linear-gradient(135deg, rgba(242,182,109,0.22), rgba(242,182,109,0.1))",
border: "1px solid rgba(242,182,109,0.32)",
color: "#f2b66d",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
flexShrink: 0,
};
const pairCardStyle: React.CSSProperties = {
padding: "12px 14px",
borderRadius: 12,
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
border: "1px solid rgba(255,255,255,0.07)",
};
const entityChipStyle: React.CSSProperties = {
display: "inline-block",
padding: "4px 10px",
borderRadius: 8,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#e6edf3",
fontSize: 12,
fontWeight: 600,
};
const actionBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 10px",
borderRadius: 8,
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
};
const iconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8b949e",
cursor: "pointer",
padding: 4,
borderRadius: 6,
display: "flex",
alignItems: "center",
};
const diffCardStyle: React.CSSProperties = {
padding: "10px 12px",
borderRadius: 10,
background: "rgba(0,0,0,0.2)",
border: "1px solid transparent",
};
const historyPanelStyle: React.CSSProperties = {
width: 240,
borderLeft: "1px solid rgba(255,255,255,0.06)",
padding: "16px 16px",
overflowY: "auto",
flexShrink: 0,
};
const historyRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 7,
padding: "8px 0",
borderBottom: "1px solid rgba(255,255,255,0.04)",
};
const emptyStateStyle: React.CSSProperties = {
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 40,
minHeight: 200,
};
const clearAllBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8b949e",
fontSize: 12,
cursor: "pointer",
padding: "2px 6px",
borderRadius: 6,
};
@@ -0,0 +1,284 @@
/**
* src/workspaces/EnrichWorkspace/RegistryTab.tsx
*
* Document Registry — a live, filterable chronological audit log of every
* KG / Ontology mutation that occurred in this session.
*/
import { useState } from "react";
import { ClipboardList, Filter, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { useRegistry, clearRegistry, type RegistryEntryOp } from "../../store/registryStore";
const OP_META: Record<
RegistryEntryOp,
{ label: string; color: string; bg: string; border: string }
> = {
import: { label: "IMPORT", color: "#4aa3ff", bg: "rgba(74,163,255,0.12)", border: "rgba(74,163,255,0.28)" },
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
"vocab-import": { label: "VOCAB", color: "#79c0ff", bg: "rgba(121,192,255,0.12)", border: "rgba(121,192,255,0.28)" },
};
const ALL_OPS: (RegistryEntryOp | "all")[] = [
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
];
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function EntryRow({ entry }: { entry: ReturnType<typeof useRegistry>[number] }) {
const [expanded, setExpanded] = useState(false);
const meta = OP_META[entry.op];
const hasDetail = entry.detail && Object.keys(entry.detail).length > 0;
return (
<div style={entryCardStyle}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
{/* Op Badge */}
<span
style={{
flexShrink: 0,
display: "inline-block",
padding: "3px 8px",
borderRadius: 999,
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.07em",
color: meta.color,
background: meta.bg,
border: `1px solid ${meta.border}`,
marginTop: 1,
}}
>
{meta.label}
</span>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 500, wordBreak: "break-word" }}>
{entry.summary}
</div>
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>
{formatDate(entry.timestamp)} · {formatTimestamp(entry.timestamp)}
</div>
</div>
{/* Expand toggle */}
{hasDetail ? (
<button
onClick={() => setExpanded((v) => !v)}
title={expanded ? "Collapse details" : "Expand details"}
style={expandBtnStyle}
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : null}
</div>
{/* Expanded detail */}
{expanded && hasDetail ? (
<pre style={detailPreStyle}>
{JSON.stringify(entry.detail, null, 2)}
</pre>
) : null}
</div>
);
}
export function RegistryTab() {
const entries = useRegistry();
const [activeFilter, setActiveFilter] = useState<RegistryEntryOp | "all">("all");
const filtered = activeFilter === "all"
? entries
: entries.filter((e) => e.op === activeFilter);
return (
<div style={shellStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ClipboardList size={18} color="#4aa3ff" />
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Document Registry</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>
Audit log of all KG and Ontology mutations this session
</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: "#8fa8c6", fontSize: 12 }}>
{entries.length} event{entries.length !== 1 ? "s" : ""}
</span>
{entries.length > 0 ? (
<button
onClick={clearRegistry}
title="Clear all events"
style={clearBtnStyle}
>
<Trash2 size={13} />
<span>Clear</span>
</button>
) : null}
</div>
</div>
{/* Filter pills */}
<div style={filterBarStyle}>
<Filter size={13} color="#8fa8c6" />
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{ALL_OPS.map((op) => {
const isActive = op === activeFilter;
const meta = op === "all" ? null : OP_META[op as RegistryEntryOp];
return (
<button
key={op}
onClick={() => setActiveFilter(op as typeof activeFilter)}
style={{
padding: "4px 10px",
borderRadius: 999,
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
border: isActive
? `1px solid ${meta?.border ?? "rgba(127,208,255,0.35)"}`
: "1px solid rgba(255,255,255,0.06)",
background: isActive
? (meta?.bg ?? "rgba(74,163,255,0.14)")
: "transparent",
color: isActive
? (meta?.color ?? "#8ed3ff")
: "#8b949e",
transition: "all 140ms ease",
}}
>
{op === "all" ? "All" : (meta?.label ?? op)}
</button>
);
})}
</div>
</div>
{/* Feed */}
<div style={feedStyle}>
{filtered.length === 0 ? (
<div style={emptyStateStyle}>
<ClipboardList size={36} color="rgba(127,208,255,0.15)" />
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
No events recorded yet
</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
Import a file, run reasoning, or merge entities to see activity appear here.
</div>
</div>
) : (
filtered.map((entry) => <EntryRow key={entry.id} entry={entry} />)
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0d1117",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 16px",
borderBottom: "1px solid rgba(88,166,255,0.1)",
flexShrink: 0,
};
const filterBarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 24px",
borderBottom: "1px solid rgba(255,255,255,0.05)",
flexShrink: 0,
};
const feedStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "16px 24px",
display: "flex",
flexDirection: "column",
gap: 8,
};
const entryCardStyle: React.CSSProperties = {
padding: "12px 14px",
borderRadius: 12,
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
border: "1px solid rgba(255,255,255,0.06)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
};
const expandBtnStyle: React.CSSProperties = {
flexShrink: 0,
background: "transparent",
border: "none",
color: "#8b949e",
cursor: "pointer",
padding: 4,
borderRadius: 6,
display: "flex",
alignItems: "center",
};
const detailPreStyle: React.CSSProperties = {
marginTop: 10,
padding: "10px 12px",
borderRadius: 8,
background: "rgba(0,0,0,0.28)",
border: "1px solid rgba(255,255,255,0.06)",
color: "#79c0ff",
fontSize: 11,
fontFamily: "'JetBrains Mono', monospace",
overflowX: "auto",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
};
const clearBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 10px",
borderRadius: 8,
border: "1px solid rgba(255,123,114,0.22)",
background: "rgba(255,123,114,0.06)",
color: "#ff7b72",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const emptyStateStyle: React.CSSProperties = {
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 40,
minHeight: 280,
};
@@ -111,16 +111,20 @@ const FA2_SETTINGS = {
const SIGMA_SETTINGS = {
allowInvalidContainer: true,
labelRenderedSizeThreshold: 4,
labelRenderedSizeThreshold: 2,
defaultNodeType: "circle",
defaultEdgeType: "line",
hideLabelsOnMove: true,
hideEdgesOnMove: true,
hideLabelsOnMove: false,
hideEdgesOnMove: false,
enableEdgeEvents: true,
renderEdgeLabels: false,
labelDensity: 0.86,
labelGridCellSize: 100,
renderEdgeLabels: true,
edgeLabelSize: 10,
edgeLabelColor: { color: "rgba(180, 210, 255, 0.72)" },
labelDensity: 1.1,
labelGridCellSize: 80,
zIndex: true,
minCameraRatio: 0.04,
maxCameraRatio: 8,
webGLTarget: "webgl2" as const,
nodeProgramClasses: SEMANTICA_NODE_PROGRAM_CLASSES,
edgeProgramClasses: SEMANTICA_EDGE_PROGRAM_CLASSES,
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { Loader2 } from "lucide-react";
import { graph } from "../../store/graphStore";
import { GRAPH_THEME } from "./graphTheme";
@@ -22,11 +22,13 @@ export interface GraphInspectorPanelProps {
predictionType: string;
onPredictionTypeChange: (value: string) => void;
onRunPredictions: () => void;
isRunningPredictions?: boolean;
pathTargetId: string;
onPathTargetChange: (value: string) => void;
onTracePath: () => void;
pathResult: PathResponse | null;
onDownloadProvenance: (format: "json" | "markdown") => void;
onFocusNode?: (nodeId: string) => void;
}
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
@@ -37,22 +39,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
.map((key) => ({ key, value: properties[key] }));
}
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
function getNodeLabel(nodeId: string): string {
if (!graph.hasNode(nodeId)) return nodeId;
const attrs = graph.getNodeAttributes(nodeId) as { label?: string; content?: string };
return String(attrs.label ?? attrs.content ?? nodeId);
}
function getEdgeLabelBetween(sourceId: string, targetId: string, edgeIds?: string[]): string {
// Try to find the specific edge from edgeIds first
if (edgeIds) {
for (const edgeId of edgeIds) {
if (graph.hasEdge(edgeId)) {
const [src, tgt] = graph.extremities(edgeId);
if ((src === sourceId && tgt === targetId) || (src === targetId && tgt === sourceId)) {
const attrs = graph.getEdgeAttributes(edgeId) as { edgeType?: string };
return attrs.edgeType ?? "→";
}
}
}
}
// Fallback: find any edge between the pair
if (graph.hasNode(sourceId) && graph.hasNode(targetId)) {
let label = "→";
graph.forEachEdge(sourceId, targetId, (_edgeId, attrs) => {
const edgeAttrs = attrs as { edgeType?: string };
if (edgeAttrs.edgeType) label = edgeAttrs.edgeType;
});
return label;
}
return "→";
}
function PathFlowViz({
path,
edgeIds,
totalWeight,
onFocusNode,
}: {
path: string[];
edgeIds?: string[];
totalWeight: number;
onFocusNode?: (nodeId: string) => void;
}) {
if (path.length === 0) {
return <div style={emptyTextStyle}>No path found between the selected nodes.</div>;
}
return (
<div>
{/* Horizontal scrollable chip flow */}
<div style={pathFlowContainerStyle}>
{path.map((nodeId, index) => {
const label = getNodeLabel(nodeId);
const edgeLabel =
index < path.length - 1
? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds)
: null;
return (
<div key={`${nodeId}-${index}`} style={{ display: "contents" }}>
{/* Node chip */}
<button
onClick={() => onFocusNode?.(nodeId)}
title={`Focus: ${nodeId}`}
style={{
...pathNodeChipStyle,
cursor: onFocusNode ? "pointer" : "default",
}}
>
<span style={pathNodeIndexStyle}>{index + 1}</span>
<span style={{ maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{label}
</span>
</button>
{/* Edge connector */}
{edgeLabel !== null ? (
<div style={pathEdgeConnectorStyle}>
<div style={{ width: 16, height: 1, background: "rgba(88,166,255,0.3)" }} />
<span style={pathEdgeLabelStyle}>{edgeLabel}</span>
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{ width: 12, height: 1, background: "rgba(88,166,255,0.3)" }} />
<div style={{ width: 0, height: 0, borderTop: "4px solid transparent", borderBottom: "4px solid transparent", borderLeft: "5px solid rgba(88,166,255,0.4)" }} />
</div>
</div>
) : null}
</div>
);
})}
</div>
{/* Weight badge */}
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: "#6a7f97", fontSize: 11 }}>Total weight:</span>
<span style={{ color: "#79c0ff", fontSize: 12, fontWeight: 700 }}>{totalWeight.toFixed(3)}</span>
<span style={{ color: "#6a7f97", fontSize: 11 }}>·</span>
<span style={{ color: "#6a7f97", fontSize: 11 }}>{path.length} hops</span>
</div>
</div>
);
}
/* ─── Main Panel ─────────────────────────────────────────────────── */
export function GraphInspectorPanel({
nodeId,
predictions,
predictionType,
onPredictionTypeChange,
onRunPredictions,
isRunningPredictions = false,
pathTargetId,
onPathTargetChange,
onTracePath,
pathResult,
onDownloadProvenance,
onFocusNode,
}: GraphInspectorPanelProps) {
if (!nodeId) {
return (
<div style={{ padding: 32, textAlign: "center" }}>
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
</div>
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
Search for a node or click one in the canvas to inspect its properties.
</p>
</div>
@@ -73,41 +185,21 @@ export function GraphInspectorPanel({
const accentColor = attributes?.color || "#58a6ff";
const propertyEntries = Object.entries(properties).filter(
([key]) =>
![
"x",
"y",
"valid_from",
"valid_until",
"content",
"source",
"source_url",
"pmid",
"pmids",
"evidence",
"provenance",
"confidence",
].includes(key),
!["x","y","valid_from","valid_until","content","source","source_url","pmid","pmids","evidence","provenance","confidence"].includes(key),
);
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
{/* Node identity */}
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span
style={{
background: accentColor,
boxShadow: `0 0 10px ${accentColor}`,
width: 8,
height: 8,
borderRadius: "50%",
}}
/>
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{String(attributes?.label ?? nodeId)}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6 }}>{nodeId}</div>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
{attributes?.valid_from || attributes?.valid_until ? (
<span style={subtleChipStyle}>temporal</span>
@@ -117,28 +209,27 @@ export function GraphInspectorPanel({
</div>
</div>
{(attributes?.valid_from || attributes?.valid_until) && (
<div
style={{
padding: "10px 12px",
background: "rgba(88, 166, 255, 0.08)",
border: "1px solid rgba(88, 166, 255, 0.2)",
borderRadius: 8,
fontSize: 12,
color: "#79c0ff",
fontFamily: "monospace",
}}
>
{/* Temporal bounds */}
{(attributes?.valid_from || attributes?.valid_until) ? (
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
</div>
)}
) : null}
{/* Actions */}
<section style={sectionStyle}>
<div style={sectionTitleStyle}>Actions</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>
Run Link Prediction
<button
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
onClick={onRunPredictions}
disabled={isRunningPredictions}
>
{isRunningPredictions ? (
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
) : null}
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
</button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
@@ -157,6 +248,7 @@ export function GraphInspectorPanel({
/>
</section>
{/* Trace Path */}
<section style={sectionStyle}>
<div style={sectionTitleStyle}>Trace Path</div>
<input
@@ -166,20 +258,22 @@ export function GraphInspectorPanel({
style={inputStyle}
/>
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
{pathResult?.path?.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
{pathResult.path.map((step, index) => (
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
))}
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>
total weight: {pathResult.total_weight.toFixed(3)}
</div>
</div>
<PathFlowViz
path={pathResult.path}
edgeIds={pathResult.edge_ids}
totalWeight={pathResult.total_weight}
onFocusNode={onFocusNode}
/>
) : (
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
<div style={emptyTextStyle}>
Choose a target or click a candidate prediction to prepare a path trace.
</div>
)}
</section>
{/* Candidate Links */}
<details className="node-panel-collapse" open={predictions.length > 0}>
<summary className="node-panel-summary">Candidate Links</summary>
<div className="node-panel-body">
@@ -191,20 +285,40 @@ export function GraphInspectorPanel({
style={predictionCardStyle}
onClick={() => onPathTargetChange(prediction.target)}
>
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>
confidence {prediction.score.toFixed(3)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
<div>
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
</div>
<div style={{ flexShrink: 0 }}>
<div style={{
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: "rgba(88,166,255,0.12)",
border: "1px solid rgba(88,166,255,0.22)",
color: "#58a6ff",
}}>
{(prediction.score * 100).toFixed(1)}%
</div>
</div>
</div>
</button>
))}
</div>
) : isRunningPredictions ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: 8, color: "#8b949e", fontSize: 12 }}>
<Loader2 size={13} className="animate-spin" />
<span>Computing candidate links</span>
</div>
) : (
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
)}
</div>
</details>
{/* Source Attribution */}
<details className="node-panel-collapse">
<summary className="node-panel-summary">Source Attribution</summary>
<div className="node-panel-body">
@@ -212,7 +326,7 @@ export function GraphInspectorPanel({
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{attribution.map(({ key, value }) => (
<div key={key} style={propertyCardStyle}>
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
@@ -225,6 +339,7 @@ export function GraphInspectorPanel({
</div>
</details>
{/* Properties */}
<details className="node-panel-collapse">
<summary className="node-panel-summary">Properties</summary>
<div className="node-panel-body">
@@ -232,7 +347,7 @@ export function GraphInspectorPanel({
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{propertyEntries.map(([key, value]) => (
<div key={key} style={propertyCardStyle}>
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
@@ -248,6 +363,8 @@ export function GraphInspectorPanel({
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const inputStyle: CSSProperties = {
width: "100%",
background: "rgba(4, 10, 18, 0.5)",
@@ -284,19 +401,12 @@ const secondaryActionButtonStyle: CSSProperties = {
const predictionCardStyle: CSSProperties = {
textAlign: "left",
padding: 12,
padding: "10px 12px",
background: "rgba(88, 166, 255, 0.08)",
border: "1px solid rgba(88, 166, 255, 0.12)",
borderRadius: 10,
cursor: "pointer",
};
const pathStepStyle: CSSProperties = {
color: "#e6edf3",
fontSize: 13,
padding: "8px 10px",
background: "rgba(255, 255, 255, 0.03)",
borderRadius: 8,
width: "100%",
};
const propertyCardStyle: CSSProperties = {
@@ -338,3 +448,58 @@ const sectionTitleStyle: CSSProperties = {
textTransform: "uppercase",
letterSpacing: "0.08em",
};
const pathFlowContainerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 0,
flexWrap: "wrap",
rowGap: 8,
};
const pathNodeChipStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "5px 10px",
borderRadius: 999,
background: "rgba(88,166,255,0.1)",
border: "1px solid rgba(88,166,255,0.22)",
color: "#e6edf3",
fontSize: 12,
fontWeight: 600,
maxWidth: 160,
};
const pathNodeIndexStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 16,
height: 16,
borderRadius: "50%",
background: "rgba(88,166,255,0.22)",
color: "#79c0ff",
fontSize: 9,
fontWeight: 800,
flexShrink: 0,
};
const pathEdgeConnectorStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 2,
flexShrink: 0,
};
const pathEdgeLabelStyle: CSSProperties = {
fontSize: 9,
fontWeight: 700,
color: "#6a7f97",
letterSpacing: "0.04em",
textTransform: "uppercase",
maxWidth: 70,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
import { logEvent } from "../../store/registryStore";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { curveGroupForPair } from "../../store/edgePairKeys.js";
import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
@@ -630,6 +631,7 @@ export function GraphWorkspace() {
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchError, setSearchError] = useState("");
const [predictionType, setPredictionType] = useState("");
const [isRunningPredictions, setIsRunningPredictions] = useState(false);
const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
const [pathTargetId, setPathTargetId] = useState("");
const [pathResult, setPathResult] = useState<PathResponse | null>(null);
@@ -814,6 +816,7 @@ export function GraphWorkspace() {
const handleRunPredictions = useCallback(async () => {
if (!selectedNodeId) return;
setIsRunningPredictions(true);
try {
const response = await fetch("/api/enrich/links", {
method: "POST",
@@ -833,6 +836,8 @@ export function GraphWorkspace() {
} catch (predictionError) {
console.error("[GraphWorkspace] prediction failed", predictionError);
setPredictions([]);
} finally {
setIsRunningPredictions(false);
}
}, [predictionType, selectedNodeId]);
@@ -899,6 +904,7 @@ export function GraphWorkspace() {
attributes: buildRealtimeNodeAttributes(payload),
},
]);
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
@@ -911,6 +917,7 @@ export function GraphWorkspace() {
attributes: buildRealtimeEdgeAttributes(payload),
},
]);
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id}${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
sceneRef.current?.getRuntime()?.requestRender();
}
} catch (socketError) {
@@ -1293,10 +1300,34 @@ export function GraphWorkspace() {
disabled: showLoadingOverlay || !searchQuery.trim(),
onClick: () => void handleSearch(),
},
{
id: "zoom-in",
label: " Zoom In",
title: "Zoom in (or scroll up on the canvas)",
onClick: () => {
const runtime = sceneRef.current?.getRuntime();
if (runtime?.renderer === "sigma") {
const camera = (runtime.scene as import("sigma").default).getCamera();
camera.animatedZoom({ duration: 200 });
}
},
},
{
id: "zoom-out",
label: " Zoom Out",
title: "Zoom out (or scroll down on the canvas)",
onClick: () => {
const runtime = sceneRef.current?.getRuntime();
if (runtime?.renderer === "sigma") {
const camera = (runtime.scene as import("sigma").default).getCamera();
camera.animatedUnzoom({ duration: 200 });
}
},
},
{
id: "fit-view",
label: "Fit View",
title: "Reset the camera to the current view",
title: "Reset the camera to fit the whole graph",
onClick: () => sceneRef.current?.fitView(),
},
{
@@ -1581,6 +1612,7 @@ export function GraphWorkspace() {
predictionType={predictionType}
onPredictionTypeChange={setPredictionType}
onRunPredictions={() => void handleRunPredictions()}
isRunningPredictions={isRunningPredictions}
pathTargetId={pathTargetId}
onPathTargetChange={setPathTargetId}
onTracePath={() => void handleTracePath()}
@@ -265,16 +265,16 @@ export const GRAPH_THEME: GraphTheme = {
],
overview: {
nodeBase: "#0B1320",
nodeCore: "#435D7A",
nodeCore: "#5A7A9E",
nodeMuted: "#121927",
nodeBorder: "#64758C",
nodeTintMix: 0.03,
nodeCoreMix: 0.52,
nodeBorder: "#7A92AE",
nodeTintMix: 0.14,
nodeCoreMix: 0.72,
nodeShellAlpha: 0.97,
nodeCoreAlpha: 1,
edgeBackbone: "rgba(83, 111, 148, 0.04)",
edgeStructure: "rgba(72, 90, 118, 0.009)",
edgeInspection: "rgba(98, 120, 148, 0.026)",
edgeBackbone: "rgba(100, 148, 210, 0.38)",
edgeStructure: "rgba(88, 140, 200, 0.28)",
edgeInspection: "rgba(110, 165, 230, 0.48)",
},
accent: {
selected: "#F2D288",
@@ -285,12 +285,12 @@ export const GRAPH_THEME: GraphTheme = {
inferred: "#D07B4D",
},
muted: {
fallback: "rgba(96, 112, 136, 0.1)",
nodeAlpha: 0.085,
edgeOverview: "rgba(82, 100, 124, 0.009)",
edgeStructure: "rgba(92, 112, 138, 0.02)",
edgeInspection: "rgba(124, 148, 176, 0.066)",
edgeFocus: "rgba(160, 186, 218, 0.16)",
fallback: "rgba(96, 112, 136, 0.18)",
nodeAlpha: 0.12,
edgeOverview: "rgba(82, 100, 124, 0.12)",
edgeStructure: "rgba(92, 112, 138, 0.18)",
edgeInspection: "rgba(124, 148, 176, 0.26)",
edgeFocus: "rgba(160, 186, 218, 0.42)",
},
background: {
canvas: "#07101A",
@@ -305,36 +305,36 @@ export const GRAPH_THEME: GraphTheme = {
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.66,
labelThreshold: 0.985,
labelBudget: 10,
edgePriorityThreshold: 0.72,
nodeScale: 0.88,
labelThreshold: 0.92,
labelBudget: 28,
edgePriorityThreshold: 0.55,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.34,
edgeSizeScale: 0.62,
showBadges: false,
showCurves: false,
showContextualArrows: false,
},
structure: {
maxRatio: 1.2,
nodeScale: 0.98,
labelThreshold: 0.88,
labelBudget: 36,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
nodeScale: 1.02,
labelThreshold: 0.82,
labelBudget: 60,
edgePriorityThreshold: 0.3,
arrowPriorityThreshold: 0.65,
edgeSizeScale: 1.05,
showBadges: true,
showCurves: true,
showContextualArrows: true,
},
inspection: {
maxRatio: 0.5,
nodeScale: 1,
labelThreshold: 0.7,
labelBudget: 80,
nodeScale: 1.08,
labelThreshold: 0.6,
labelBudget: 120,
edgePriorityThreshold: 0,
arrowPriorityThreshold: 0.58,
edgeSizeScale: 1.04,
arrowPriorityThreshold: 0.45,
edgeSizeScale: 1.18,
showBadges: true,
showCurves: true,
showContextualArrows: true,
@@ -398,13 +398,13 @@ export const GRAPH_THEME: GraphTheme = {
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
},
variants: {
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
@@ -437,14 +437,14 @@ export const GRAPH_THEME: GraphTheme = {
},
edges: {
states: {
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
},
variants: {
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
@@ -4,6 +4,7 @@
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
@@ -107,6 +108,11 @@ export function ImportExportWorkspace() {
const data = await res.json();
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
file: file.name,
nodesImported: data.nodes_imported,
edgesImported: data.edges_imported,
});
setFile(null);
} catch (err: any) {
showToast("error", err.message || "An error occurred during import");
@@ -142,6 +148,7 @@ export function ImportExportWorkspace() {
document.body.removeChild(a);
showToast("success", "Export complete! Your download should begin shortly.");
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
} catch (err: any) {
showToast("error", err.message || "An error occurred during export");
} finally {
@@ -0,0 +1,339 @@
/**
* src/workspaces/ManageWorkspace/KGOverviewTab.tsx
*
* Quick-view dashboard for the Knowledge Graph: node/edge counts,
* type distributions, and top connected nodes.
*/
import { useState, useEffect, useCallback } from "react";
import { Network, RefreshCw, Loader2 } from "lucide-react";
interface KGStats {
node_count: number;
edge_count: number;
node_types?: Record<string, number>;
edge_types?: Record<string, number>;
[key: string]: unknown;
}
interface NodeItem {
id: string;
type: string;
content: string;
properties?: Record<string, unknown>;
}
interface NodeListResponse {
nodes: NodeItem[];
total: number;
}
function TypeBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "5px 0" }}>
<div style={{ width: 120, flexShrink: 0, color: "#c6d4e3", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={label}>
{label}
</div>
<div style={{ flex: 1, height: 6, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 999,
background: color,
transition: "width 400ms ease",
}}
/>
</div>
<div style={{ width: 52, textAlign: "right", flexShrink: 0, display: "flex", gap: 6, justifyContent: "flex-end" }}>
<span style={{ color: "#8b949e", fontSize: 11 }}>{count.toLocaleString()}</span>
<span style={{ color: "#6a7f97", fontSize: 11 }}>{pct}%</span>
</div>
</div>
);
}
const NODE_COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff", "#f2b66d"];
const EDGE_COLORS = ["#4cc38a", "#79c0ff", "#d2a8ff", "#f2b66d", "#ff7b72", "#58a6ff", "#4aa3ff", "#8A56D8"];
function buildTypeMap(nodes: NodeItem[], key: keyof NodeItem): Record<string, number> {
const map: Record<string, number> = {};
for (const node of nodes) {
const val = String(node[key] ?? "unknown");
map[val] = (map[val] ?? 0) + 1;
}
return map;
}
export function KGOverviewTab() {
const [stats, setStats] = useState<KGStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [topNodes, setTopNodes] = useState<{ node: NodeItem; neighborCount: number }[]>([]);
const [nodeTypeMap, setNodeTypeMap] = useState<Record<string, number>>({});
const fetchOverview = useCallback(async () => {
setLoading(true);
setError("");
try {
const [statsRes, nodesRes] = await Promise.all([
fetch("/api/graph/stats"),
fetch("/api/graph/nodes?limit=500"),
]);
if (statsRes.ok) {
const statsData: KGStats = await statsRes.json();
setStats(statsData);
}
if (nodesRes.ok) {
const nodesData: NodeListResponse = await nodesRes.json();
const nodes = nodesData.nodes ?? [];
setNodeTypeMap(buildTypeMap(nodes, "type"));
// Simulate neighbor counts via edges fetch for top-N
const edgesRes = await fetch("/api/graph/edges?limit=2000");
if (edgesRes.ok) {
const edgesData = await edgesRes.json();
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
const degreeMap: Record<string, number> = {};
for (const edge of edges) {
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
}
const sorted = nodes
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
.sort((a, b) => b.neighborCount - a.neighborCount)
.slice(0, 10);
setTopNodes(sorted);
}
}
} catch {
setError("Failed to load graph overview. Ensure the server is running.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchOverview();
}, [fetchOverview]);
const nodeTypeEntries = Object.entries(nodeTypeMap).sort((a, b) => b[1] - a[1]);
const edgeTypeEntries = stats?.edge_types
? Object.entries(stats.edge_types).sort((a, b) => b[1] - a[1])
: [];
const totalNodes = stats?.node_count ?? 0;
const totalEdges = stats?.edge_count ?? 0;
return (
<div style={shellStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Network size={18} color="#4aa3ff" />
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
</div>
</div>
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
<span>Refresh</span>
</button>
</div>
{error ? (
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
{error}
</div>
) : null}
<div style={scrollBodyStyle}>
{/* Stats chips */}
<div style={statsRowStyle}>
{[
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
].map(({ label, value, color, sub }) => (
<div key={label} style={statCardStyle}>
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
</div>
))}
</div>
{/* Type breakdowns */}
<div style={sectionRowStyle}>
{/* Node types */}
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Node Type Breakdown</div>
{loading ? (
<div style={skeletonWrapStyle}>
{[80, 65, 45, 35, 25].map((w, i) => (
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
))}
</div>
) : nodeTypeEntries.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data load the graph first.</div>
) : (
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
))
)}
</div>
{/* Edge types */}
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
{loading ? (
<div style={skeletonWrapStyle}>
{[70, 55, 48, 30, 20].map((w, i) => (
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
))}
</div>
) : edgeTypeEntries.length === 0 ? (
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
) : (
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
))
)}
</div>
</div>
{/* Top connected nodes */}
{topNodes.length > 0 ? (
<div style={breakdownCardStyle}>
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
{topNodes.map(({ node, neighborCount }, rank) => (
<div key={node.id} style={topNodeRowStyle}>
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{node.content || node.id}
</div>
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
</div>
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
{neighborCount} conn.
</div>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0d1117",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 16px",
borderBottom: "1px solid rgba(88,166,255,0.1)",
flexShrink: 0,
};
const refreshBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(74,163,255,0.08)",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const scrollBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "20px 24px",
display: "flex",
flexDirection: "column",
gap: 16,
};
const statsRowStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: 12,
};
const statCardStyle: React.CSSProperties = {
padding: "18px 20px",
borderRadius: 16,
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
border: "1px solid rgba(127,208,255,0.1)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
};
const sectionRowStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
};
const breakdownCardStyle: React.CSSProperties = {
padding: "16px 18px",
borderRadius: 14,
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
border: "1px solid rgba(255,255,255,0.06)",
display: "flex",
flexDirection: "column",
gap: 8,
};
const sectionTitleStyle: React.CSSProperties = {
color: "#8b949e",
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.07em",
marginBottom: 4,
};
const topNodeRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 10,
background: "rgba(255,255,255,0.025)",
border: "1px solid rgba(255,255,255,0.05)",
};
const skeletonWrapStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 8,
marginTop: 4,
};
const skeletonBarStyle: React.CSSProperties = {
height: 12,
borderRadius: 999,
background: "rgba(255,255,255,0.05)",
animation: "skeleton-pulse 1.4s ease-in-out infinite",
};
@@ -0,0 +1,346 @@
/**
* src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
*
* A compact read-only view of all loaded SKOS ConceptSchemes and their
* top-level concepts. Clicking a concept deep-links to the Vocabulary Browser.
*/
import { useState } from "react";
import { BookOpen, ChevronRight, ChevronDown, ExternalLink } from "lucide-react";
import { useVocabularies, useConceptHierarchy } from "../VocabularyWorkspace/queries";
import type { ConceptNode, VocabularyScheme } from "../VocabularyWorkspace/types";
function countConcepts(nodes: ConceptNode[]): number {
return nodes.reduce((acc, node) => {
return acc + 1 + countConcepts(node.children ?? []);
}, 0);
}
function ConceptRow({
concept,
depth,
onSelect,
}: {
concept: ConceptNode;
depth: number;
onSelect: (concept: ConceptNode) => void;
}) {
const [expanded, setExpanded] = useState(false);
const children = concept.children ?? [];
const hasChildren = children.length > 0;
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
paddingLeft: 12 + depth * 16,
paddingRight: 12,
paddingTop: 5,
paddingBottom: 5,
borderRadius: 6,
cursor: "pointer",
color: depth === 0 ? "#c6d4e3" : "#8b949e",
fontSize: depth === 0 ? 13 : 12,
transition: "background 120ms ease",
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.07)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
>
{hasChildren ? (
<button
onClick={() => setExpanded((v) => !v)}
style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", padding: 0, display: "flex", alignItems: "center" }}
>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : (
<span style={{ width: 12, display: "inline-block" }} />
)}
<span
onClick={() => onSelect(concept)}
style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{concept.pref_label || concept.uri}
</span>
{children.length > 0 ? (
<span style={{ color: "#6a7f97", fontSize: 10 }}>{children.length}</span>
) : null}
</div>
{expanded && hasChildren
? children.map((child) => (
<ConceptRow key={child.uri} concept={child} depth={depth + 1} onSelect={onSelect} />
))
: null}
</>
);
}
function SchemePanel({
scheme,
onSelectConcept,
}: {
scheme: VocabularyScheme;
onSelectConcept: (concept: ConceptNode) => void;
}) {
const [expanded, setExpanded] = useState(true);
const { data: hierarchy = [], isLoading } = useConceptHierarchy(scheme.uri);
const totalConcepts = countConcepts(hierarchy);
return (
<div style={schemeCardStyle}>
{/* Scheme header */}
<button
onClick={() => setExpanded((v) => !v)}
style={schemeHeaderStyle}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{expanded ? <ChevronDown size={14} color="#8b949e" /> : <ChevronRight size={14} color="#8b949e" />}
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.label}</span>
</div>
<span style={{ color: "#6a7f97", fontSize: 11 }}>
{isLoading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
</span>
</button>
{/* Concept tree */}
{expanded ? (
<div style={{ paddingTop: 4, paddingBottom: 8 }}>
{isLoading ? (
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12 }}>Loading concepts</div>
) : hierarchy.length === 0 ? (
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
No concepts found in this scheme.
</div>
) : (
hierarchy.map((concept) => (
<ConceptRow key={concept.uri} concept={concept} depth={0} onSelect={onSelectConcept} />
))
)}
</div>
) : null}
</div>
);
}
export function OntologySummaryTab({
onOpenVocabularyBrowser,
}: {
onOpenVocabularyBrowser?: () => void;
}) {
const { data: schemes = [], isLoading } = useVocabularies();
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
return (
<div style={shellStyle}>
{/* Header */}
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<BookOpen size={18} color="#d2a8ff" />
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Ontology Summary</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>
{isLoading
? "Loading schemes…"
: `${schemes.length} vocabulary scheme${schemes.length !== 1 ? "s" : ""} loaded`}
</div>
</div>
</div>
{onOpenVocabularyBrowser ? (
<button onClick={onOpenVocabularyBrowser} style={openBrowserBtnStyle}>
<ExternalLink size={12} />
<span>Open Full Browser</span>
</button>
) : null}
</div>
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
{/* Scheme tree column */}
<div style={treeColumnStyle}>
{isLoading ? (
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 10 }}>
{[90, 75, 60].map((w, i) => (
<div key={i} style={{ height: 36, borderRadius: 8, background: "rgba(255,255,255,0.04)", width: `${w}%` }} />
))}
</div>
) : schemes.length === 0 ? (
<div style={emptyStateStyle}>
<BookOpen size={32} color="rgba(210,168,255,0.15)" />
<div style={{ color: "#8b949e", fontSize: 13, marginTop: 12 }}>No vocabulary schemes loaded</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 240 }}>
Import a .ttl or .rdf file via the Vocabulary Browser to see your ontology here.
</div>
</div>
) : (
<div style={{ padding: "12px 8px", display: "flex", flexDirection: "column", gap: 8 }}>
{schemes.map((scheme) => (
<SchemePanel key={scheme.uri} scheme={scheme} onSelectConcept={setSelectedConcept} />
))}
</div>
)}
</div>
{/* Concept detail panel */}
{selectedConcept ? (
<div style={detailPanelStyle}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
<div style={{ color: "#d2a8ff", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
Concept Detail
</div>
<button onClick={() => setSelectedConcept(null)} style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", fontSize: 16 }}>×</button>
</div>
<h3 style={{ color: "#ffffff", fontSize: 18, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 6px 0" }}>
{selectedConcept.pref_label}
</h3>
{selectedConcept.notation ? (
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 8 }}>Notation: {selectedConcept.notation}</div>
) : null}
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all", marginBottom: 14 }}>
{selectedConcept.uri}
</div>
{selectedConcept.description ? (
<div style={detailSectionStyle}>
<div style={detailLabelStyle}>Description</div>
<div style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>{selectedConcept.description}</div>
</div>
) : null}
{selectedConcept.alt_labels?.length ? (
<div style={detailSectionStyle}>
<div style={detailLabelStyle}>Alternative Labels</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedConcept.alt_labels.map((label) => (
<span key={label} style={altLabelChipStyle}>{label}</span>
))}
</div>
</div>
) : null}
{(selectedConcept.children?.length ?? 0) > 0 ? (
<div style={detailSectionStyle}>
<div style={detailLabelStyle}>Narrower Concepts ({selectedConcept.children!.length})</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{selectedConcept.children!.slice(0, 8).map((child) => (
<div
key={child.uri}
onClick={() => setSelectedConcept(child)}
style={{ color: "#79c0ff", fontSize: 12, cursor: "pointer", padding: "3px 0" }}
>
{child.pref_label}
</div>
))}
{selectedConcept.children!.length > 8 ? (
<div style={{ color: "#6a7f97", fontSize: 11 }}>+{selectedConcept.children!.length - 8} more</div>
) : null}
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0d1117",
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "20px 24px 16px",
borderBottom: "1px solid rgba(88,166,255,0.1)",
flexShrink: 0,
};
const openBrowserBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: 8,
border: "1px solid rgba(210,168,255,0.22)",
background: "rgba(210,168,255,0.08)",
color: "#d2a8ff",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const treeColumnStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
borderRight: "1px solid rgba(255,255,255,0.06)",
};
const schemeCardStyle: React.CSSProperties = {
borderRadius: 10,
border: "1px solid rgba(210,168,255,0.1)",
background: "rgba(255,255,255,0.02)",
overflow: "hidden",
};
const schemeHeaderStyle: React.CSSProperties = {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 14px",
background: "transparent",
border: "none",
cursor: "pointer",
borderBottom: "1px solid rgba(255,255,255,0.05)",
};
const detailPanelStyle: React.CSSProperties = {
width: 300,
padding: "20px",
overflowY: "auto",
borderLeft: "1px solid rgba(255,255,255,0.06)",
flexShrink: 0,
};
const detailSectionStyle: React.CSSProperties = {
marginTop: 14,
paddingTop: 12,
borderTop: "1px solid rgba(255,255,255,0.06)",
};
const detailLabelStyle: React.CSSProperties = {
color: "#8b949e",
fontSize: 10,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.07em",
marginBottom: 6,
};
const altLabelChipStyle: React.CSSProperties = {
padding: "3px 8px",
borderRadius: 999,
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#8fa8c6",
fontSize: 11,
};
const emptyStateStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: 40,
height: "100%",
};
@@ -1,13 +1,15 @@
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] })
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
base: '/',
+242
View File
@@ -0,0 +1,242 @@
# Semantica MCP Server
A fully modular [Model Context Protocol](https://modelcontextprotocol.io/) server for the Semantica knowledge graph.
Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot), and any other MCP-compatible AI tool directly to your Semantica graph.
---
## Quick start
```bash
# From the repo root
pip install -e ".[mcp]"
# Test the server (type a JSON-RPC request, press Enter)
python -m mcp
```
Or point your AI tool at it (see per-tool configs below).
---
## Transport
**stdio** — the server reads newline-delimited JSON-RPC 2.0 from `stdin` and writes responses to `stdout`.
Log/debug output goes to `stderr` only.
```
python -m mcp [--debug]
```
---
## Tools (17 total)
### Extraction
| Tool | Description |
|---|---|
| `extract_entities` | Named entity recognition (NER) — people, places, orgs, concepts |
| `extract_relations` | Relation extraction + (subject, predicate, object) triplets |
| `extract_all` | Full pipeline: NER + coreference + relations + events + triplets |
### Decision Intelligence
| Tool | Description |
|---|---|
| `record_decision` | Record a decision with context, confidence, causal links |
| `query_decisions` | Query decisions by natural language or structured filters |
| `find_precedents` | Find past decisions similar to a scenario (hybrid similarity) |
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
| `analyze_decision_impact` | Analyse downstream influence of a decision |
### Knowledge Graph
| Tool | Description |
|---|---|
| `add_entity` | Add a node/entity to the graph |
| `add_relationship` | Add a directed edge between two entities |
| `search_graph` | Search nodes by label or ID substring |
| `get_graph_summary` | Node/edge counts, decision count, type breakdown |
| `get_graph_analytics` | PageRank, betweenness, degree centrality, community detection |
### Reasoning
| Tool | Description |
|---|---|
| `run_reasoning` | Forward-chaining IF/THEN rules over facts |
| `abductive_reasoning` | Generate plausible hypotheses for observations |
### Export & Provenance
| Tool | Description |
|---|---|
| `export_graph` | Export graph to JSON, CSV, GraphML, Parquet, Turtle, N-Triples, RDF/XML, JSON-LD |
| `get_provenance` | Audit history and source lineage for a node |
---
## Resources (4 total)
| URI | Description |
|---|---|
| `semantica://graph/summary` | Live node/edge counts and type breakdown |
| `semantica://decisions/list` | Most recent 50 decisions |
| `semantica://schema/info` | Schema version, node/edge types, tool names |
| `semantica://ontology/schema` | Full ontology schema |
---
## Per-tool configuration
### Claude Code (`~/.claude/settings.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
Or use the plugin bundle:
```bash
claude mcp add semantica python -m mcp --cwd /path/to/semantica
```
---
### Cursor (`~/.cursor/mcp.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Cline (VS Code extension settings)
In your VS Code `settings.json`:
```json
{
"cline.mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
### Continue (`~/.continue/config.json`)
```json
{
"mcpServers": [
{
"name": "semantica",
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
]
}
```
---
### VS Code (GitHub Copilot) — `.vscode/mcp.json`
```json
{
"servers": {
"semantica": {
"type": "stdio",
"command": "python",
"args": ["-m", "mcp"],
"cwd": "${workspaceFolder}"
}
}
}
```
---
### Amazon Q Developer
Add to your Q Developer MCP config:
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp"],
"cwd": "/path/to/semantica"
}
}
}
```
---
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
---
## Package structure
```
mcp/
├── __init__.py # Package entry, re-exports SemanticaMCPServer + main
├── __main__.py # python -m mcp entry point
├── server.py # SemanticaMCPServer class + stdio event loop
├── session.py # Lazy ContextGraph singleton (get_graph / reset_graph)
├── schemas.py # JSON Schema definitions for all tool inputs
├── tools/
│ ├── __init__.py # Assembles TOOL_DEFINITIONS list
│ ├── extraction.py # NER, relation extraction, full pipeline
│ ├── decisions.py # Record, query, precedents, causal chain, impact
│ ├── graph.py # Add entity/relationship, search, summary, analytics
│ ├── reasoning.py # Forward-chaining rules, abductive hypotheses
│ └── export.py # Graph export (multi-format) + provenance
└── resources/
├── __init__.py # Re-exports RESOURCE_DEFINITIONS + handle_resource_read
└── registry.py # URI → handler map for the 4 semantica:// resources
```
+27
View File
@@ -0,0 +1,27 @@
"""
Semantica MCP Server Package
A full Model Context Protocol (MCP) server for Semantica — exposes knowledge graph
construction, semantic extraction, decision intelligence, reasoning, analytics,
and export capabilities as MCP tools and resources.
Run the server:
python -m mcp.server # from repo root
python -m semantica.mcp_server # alias inside installed package
Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "mcp.server"],
"cwd": "/path/to/semantica"
}
}
}
"""
from .server import SemanticaMCPServer, main
__all__ = ["SemanticaMCPServer", "main"]
__version__ = "0.4.0"
+5
View File
@@ -0,0 +1,5 @@
"""Entry point: python -m mcp.server"""
from mcp.server import main
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""
MCP resource registry — static and dynamic resources exposed via resources/list
and resources/read.
"""
from .registry import RESOURCE_DEFINITIONS, handle_resource_read
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
+153
View File
@@ -0,0 +1,153 @@
"""
Resource handlers for Semantica MCP resources.
Each resource maps a semantica:// URI to a callable that returns
{"uri": ..., "mimeType": ..., "text": ...}.
"""
from __future__ import annotations
import json
import logging
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.resources")
def _read_graph_summary(uri: str) -> dict:
try:
graph = get_graph()
all_nodes = list(graph.find_nodes())
node_types: dict[str, int] = {}
for n in all_nodes:
t = str(n.get("type", "Unknown"))
node_types[t] = node_types.get(t, 0) + 1
edge_count = 0
if hasattr(graph, "edge_count"):
try:
edge_count = graph.edge_count()
except Exception as exc:
log.debug("Unable to read graph edge_count(); defaulting to 0: %s", exc)
data = {
"node_count": len(all_nodes),
"edge_count": edge_count,
"node_types": node_types,
}
except Exception as exc:
data = {"error": str(exc)}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
def _read_decisions_list(uri: str) -> dict:
try:
graph = get_graph()
nodes = list(graph.find_nodes(node_type="decision"))
decisions = [
{
"id": n.get("id"),
"category": n.get("category"),
"outcome": n.get("outcome"),
"scenario": str(n.get("scenario", ""))[:120],
}
for n in nodes[:50]
]
data = {"decisions": decisions, "count": len(decisions)}
except Exception as exc:
data = {"error": str(exc), "decisions": []}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
def _read_schema_info(uri: str) -> dict:
info = {
"version": "0.4.0",
"node_types": [
"Entity", "decision", "Decision", "Event", "Concept",
"Person", "Organisation", "Location",
],
"edge_types": [
"RELATED_TO", "CAUSED_BY", "LEADS_TO", "PART_OF",
"INSTANCE_OF", "SIMILAR_TO",
],
"tools": [
"extract_entities", "extract_relations", "extract_all",
"record_decision", "query_decisions", "find_precedents",
"get_causal_chain", "analyze_decision_impact",
"add_entity", "add_relationship", "search_graph",
"get_graph_summary", "get_graph_analytics",
"run_reasoning", "abductive_reasoning",
"export_graph", "get_provenance",
],
}
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(info, indent=2)}
def _read_ontology_schema(uri: str) -> dict:
try:
graph = get_graph()
try:
from semantica.ontology import OntologyManager
mgr = OntologyManager(graph_store=graph)
schema = mgr.get_schema()
text = json.dumps(schema, indent=2) if isinstance(schema, dict) else str(schema)
except (ImportError, AttributeError):
text = json.dumps({"message": "Ontology manager not available"}, indent=2)
except Exception as exc:
text = json.dumps({"error": str(exc)}, indent=2)
return {"uri": uri, "mimeType": "application/json", "text": text}
# Map URI → handler
_HANDLERS: dict[str, object] = {
"semantica://graph/summary": _read_graph_summary,
"semantica://decisions/list": _read_decisions_list,
"semantica://schema/info": _read_schema_info,
"semantica://ontology/schema": _read_ontology_schema,
}
RESOURCE_DEFINITIONS = [
{
"uri": "semantica://graph/summary",
"name": "Graph Summary",
"description": "High-level summary of the current knowledge graph: node/edge counts and type breakdown.",
"mimeType": "application/json",
},
{
"uri": "semantica://decisions/list",
"name": "Decision List",
"description": "Most recent decisions recorded in the knowledge graph (up to 50).",
"mimeType": "application/json",
},
{
"uri": "semantica://schema/info",
"name": "Schema Info",
"description": "Semantica schema version, supported node/edge types, and available tool names.",
"mimeType": "application/json",
},
{
"uri": "semantica://ontology/schema",
"name": "Ontology Schema",
"description": "Full ontology schema from the OntologyManager (concept hierarchy and constraints).",
"mimeType": "application/json",
},
]
def handle_resource_read(uri: str) -> dict:
"""Dispatch a resources/read request to the appropriate handler."""
handler = _HANDLERS.get(uri)
if handler is None:
return {
"uri": uri,
"mimeType": "application/json",
"text": json.dumps({"error": f"Unknown resource URI: {uri}"}),
}
try:
return handler(uri) # type: ignore[call-arg]
except Exception as exc:
log.exception("resource_read failed for %s", uri)
return {
"uri": uri,
"mimeType": "application/json",
"text": json.dumps({"error": str(exc)}),
}
+292
View File
@@ -0,0 +1,292 @@
"""
Input schema definitions for all MCP tools.
Each entry is the JSON Schema object placed in the tool's ``inputSchema``
field. Keeping them here avoids duplication across tool modules.
"""
EXTRACTION_TEXT = {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Input text to process",
}
},
"required": ["text"],
}
EXTRACT_ENTITIES = EXTRACTION_TEXT
EXTRACT_RELATIONS = EXTRACTION_TEXT
EXTRACT_ALL = {
"type": "object",
"properties": {
"text": {"type": "string", "description": "Input text to process"},
"include_events": {
"type": "boolean",
"description": "Also extract events (default: true)",
},
"include_triplets": {
"type": "boolean",
"description": "Also extract (subject, predicate, object) triplets (default: true)",
},
},
"required": ["text"],
}
RECORD_DECISION = {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Decision category, e.g. 'loan_approval', 'deployment'",
},
"scenario": {
"type": "string",
"description": "Natural-language description of the situation",
},
"reasoning": {
"type": "string",
"description": "Explanation of why this decision was made",
},
"outcome": {
"type": "string",
"description": "Decision result, e.g. 'approved', 'rejected', 'deferred'",
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score between 0 and 1",
},
"decision_maker": {
"type": "string",
"description": "Who or what made the decision (default: mcp_client)",
},
"valid_from": {
"type": "string",
"description": "ISO 8601 validity start date (optional)",
},
"valid_until": {
"type": "string",
"description": "ISO 8601 validity end date (optional)",
},
},
"required": ["category", "scenario", "reasoning", "outcome", "confidence"],
}
QUERY_DECISIONS = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural language query (optional)",
},
"category": {
"type": "string",
"description": "Filter by exact category (optional)",
},
"outcome": {
"type": "string",
"description": "Filter by outcome value (optional)",
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Maximum number of results (default: 10)",
},
},
}
FIND_PRECEDENTS = {
"type": "object",
"properties": {
"scenario": {
"type": "string",
"description": "Scenario description to find similar past decisions for",
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Maximum number of precedents to return (default: 5)",
},
},
"required": ["scenario"],
}
GET_CAUSAL_CHAIN = {
"type": "object",
"properties": {
"decision_id": {
"type": "string",
"description": "ID of the decision to trace",
},
"direction": {
"type": "string",
"enum": ["upstream", "downstream", "both"],
"description": "Trace direction (default: downstream)",
},
"max_depth": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "Maximum chain depth (default: 5)",
},
},
"required": ["decision_id"],
}
ANALYZE_DECISION_IMPACT = {
"type": "object",
"properties": {
"decision_id": {
"type": "string",
"description": "ID of the decision to analyse",
},
},
"required": ["decision_id"],
}
ADD_ENTITY = {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique node identifier",
},
"label": {
"type": "string",
"description": "Human-readable label (defaults to id)",
},
"type": {
"type": "string",
"description": "Node type, e.g. 'Person', 'Organisation', 'Concept'",
},
"metadata": {
"type": "object",
"description": "Additional key-value properties",
},
},
"required": ["id"],
}
ADD_RELATIONSHIP = {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Source node ID",
},
"target": {
"type": "string",
"description": "Target node ID",
},
"type": {
"type": "string",
"description": "Relationship type, e.g. 'WORKS_AT', 'CAUSED_BY'",
},
"metadata": {
"type": "object",
"description": "Additional edge properties",
},
},
"required": ["source", "target"],
}
SEARCH_GRAPH = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term or phrase",
},
"node_type": {
"type": "string",
"description": "Filter by node type (optional)",
},
"limit": {
"type": "integer",
"description": "Max results (default: 20)",
},
},
"required": ["query"],
}
RUN_REASONING = {
"type": "object",
"properties": {
"facts": {
"type": "array",
"items": {"type": "string"},
"description": "Fact strings, e.g. ['Person(John)', 'Employee(John)']",
},
"rules": {
"type": "array",
"items": {"type": "string"},
"description": "IF/THEN rule strings, e.g. ['IF Employee(?x) THEN Worker(?x)']",
},
},
"required": ["facts", "rules"],
}
ABDUCTIVE_REASONING = {
"type": "object",
"properties": {
"observations": {
"type": "array",
"items": {"type": "string"},
"description": "Observed facts to explain",
},
"max_hypotheses": {
"type": "integer",
"description": "Max hypotheses to generate (default: 5)",
},
},
"required": ["observations"],
}
EXPORT_GRAPH = {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json", "csv"],
"description": "Export format (default: json-ld)",
},
},
}
GET_PROVENANCE = {
"type": "object",
"properties": {
"entity_id": {
"type": "string",
"description": "Entity or node ID to get provenance for",
},
},
"required": ["entity_id"],
}
GET_ANALYTICS = {
"type": "object",
"properties": {
"metrics": {
"type": "array",
"items": {
"type": "string",
"enum": ["pagerank", "betweenness", "communities", "degree", "all"],
},
"description": "Analytics to compute (default: ['all'])",
},
"top_n": {
"type": "integer",
"description": "Top N nodes to return per metric (default: 10)",
},
},
}
EMPTY = {"type": "object", "properties": {}}
+223
View File
@@ -0,0 +1,223 @@
"""
Semantica MCP Server — JSON-RPC 2.0 over stdio.
Implements the Model Context Protocol so any MCP-compatible AI tool
(Claude Code, Cursor, Windsurf, Cline, Continue, VS Code Copilot, etc.)
can interact with the Semantica knowledge graph.
Run:
python -m mcp # via __main__.py
python -m mcp.server # direct
"""
from __future__ import annotations
import json
import logging
import sys
from typing import Any
from mcp.resources import RESOURCE_DEFINITIONS, handle_resource_read
from mcp.tools import TOOL_DEFINITIONS
log = logging.getLogger("semantica.mcp.server")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ok(request_id: Any, result: Any) -> dict:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
def _err(request_id: Any, code: int, message: str, data: Any = None) -> dict:
error: dict = {"code": code, "message": message}
if data is not None:
error["data"] = data
return {"jsonrpc": "2.0", "id": request_id, "error": error}
# JSON-RPC error codes
_PARSE_ERROR = -32700
_METHOD_NOT_FOUND = -32601
_INVALID_PARAMS = -32602
_INTERNAL_ERROR = -32603
# ---------------------------------------------------------------------------
# Tool dispatch index
# ---------------------------------------------------------------------------
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
# ---------------------------------------------------------------------------
# Request handlers
# ---------------------------------------------------------------------------
def _handle_initialize(req_id: Any, params: dict) -> dict:
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
"resources": {},
},
"serverInfo": {
"name": "semantica-mcp",
"version": "0.4.0",
},
})
def _handle_tools_list(req_id: Any, _params: dict) -> dict:
tools = [
{
"name": t["name"],
"description": t["description"],
"inputSchema": t["inputSchema"],
}
for t in TOOL_DEFINITIONS
]
return _ok(req_id, {"tools": tools})
def _handle_tools_call(req_id: Any, params: dict) -> dict:
name = params.get("name", "")
args = params.get("arguments", {}) or {}
tool = _TOOL_INDEX.get(name)
if tool is None:
return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {name}")
try:
result = tool["_handler"](args)
except Exception as exc:
log.exception("Tool %s raised an exception", name)
return _err(req_id, _INTERNAL_ERROR, str(exc))
# MCP spec: content must be a list of content items
return _ok(req_id, {
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
"isError": "error" in result,
})
def _handle_resources_list(req_id: Any, _params: dict) -> dict:
return _ok(req_id, {"resources": RESOURCE_DEFINITIONS})
def _handle_resources_read(req_id: Any, params: dict) -> dict:
uri = params.get("uri", "").strip()
if not uri:
return _err(req_id, _INVALID_PARAMS, "uri is required")
resource = handle_resource_read(uri)
return _ok(req_id, {
"contents": [
{
"uri": resource["uri"],
"mimeType": resource.get("mimeType", "application/json"),
"text": resource.get("text", ""),
}
]
})
def _handle_ping(req_id: Any, _params: dict) -> dict:
return _ok(req_id, {})
# ---------------------------------------------------------------------------
# Dispatch table
# ---------------------------------------------------------------------------
_DISPATCH = {
"initialize": _handle_initialize,
"tools/list": _handle_tools_list,
"tools/call": _handle_tools_call,
"resources/list": _handle_resources_list,
"resources/read": _handle_resources_read,
"ping": _handle_ping,
}
# ---------------------------------------------------------------------------
# Main server class
# ---------------------------------------------------------------------------
class SemanticaMCPServer:
"""Semantica MCP server — reads JSON-RPC requests from stdin, writes to stdout."""
def __init__(self, *, debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.WARNING
logging.basicConfig(stream=sys.stderr, level=level,
format="%(name)s %(levelname)s %(message)s")
# ------------------------------------------------------------------
def dispatch(self, request: dict) -> dict | None:
"""Process one JSON-RPC request and return a response dict (or None for notifications)."""
req_id = request.get("id") # None for notifications
method = request.get("method", "")
params = request.get("params") or {}
handler = _DISPATCH.get(method)
if handler is None:
if req_id is None:
return None # Notification — ignore unknown methods silently
return _err(req_id, _METHOD_NOT_FOUND, f"Method not found: {method}")
try:
return handler(req_id, params)
except Exception as exc:
log.exception("Unhandled error in method %s", method)
if req_id is None:
return None
return _err(req_id, _INTERNAL_ERROR, str(exc))
# ------------------------------------------------------------------
def run(self) -> None:
"""Start the stdio event loop."""
log.info("Semantica MCP server starting (stdio)")
for raw_line in sys.stdin:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
request = json.loads(raw_line)
except json.JSONDecodeError as exc:
response = _err(None, _PARSE_ERROR, f"Parse error: {exc}")
_write(response)
continue
if isinstance(request, list):
# Batch request
responses = []
for req in request:
resp = self.dispatch(req)
if resp is not None:
responses.append(resp)
if responses:
_write(responses)
else:
resp = self.dispatch(request)
if resp is not None:
_write(resp)
def _write(obj: Any) -> None:
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="Semantica MCP Server")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
args = parser.parse_args()
SemanticaMCPServer(debug=args.debug).run()
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
"""
Shared graph session — lazy singleton across all tool handlers.
The graph is initialised once on first access and shared for the
lifetime of the MCP server process. Set SEMANTICA_KG_PATH to
automatically load a persisted graph on start.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Optional
log = logging.getLogger("semantica.mcp.session")
_graph: Optional[Any] = None
def get_graph() -> Any:
"""
Return the shared ContextGraph instance, creating it on first call.
The graph is created with advanced_analytics=True so all centrality,
community-detection, and embedding features are available.
"""
global _graph
if _graph is None:
from semantica.context import ContextGraph
_graph = ContextGraph(advanced_analytics=True)
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
if kg_path and os.path.exists(kg_path):
try:
_graph.load(kg_path)
log.info("Graph loaded from %s", kg_path)
except Exception as exc:
log.warning("Could not load graph from %s: %s", kg_path, exc)
return _graph
def reset_graph() -> None:
"""Reset the singleton (mainly useful in tests)."""
global _graph
_graph = None
+22
View File
@@ -0,0 +1,22 @@
"""
MCP tool registry — imports all tool handlers and assembles TOOL_DEFINITIONS.
Each module under mcp/tools/ registers its handlers here.
"""
from .decisions import DECISION_TOOLS
from .export import EXPORT_TOOLS
from .extraction import EXTRACTION_TOOLS
from .graph import GRAPH_TOOLS
from .reasoning import REASONING_TOOLS
# Ordered list — exposed to the MCP client via tools/list
TOOL_DEFINITIONS = (
EXTRACTION_TOOLS
+ DECISION_TOOLS
+ GRAPH_TOOLS
+ REASONING_TOOLS
+ EXPORT_TOOLS
)
__all__ = ["TOOL_DEFINITIONS"]
+166
View File
@@ -0,0 +1,166 @@
"""
Decision intelligence tools — record, query, precedents, causal chain, impact.
"""
from __future__ import annotations
import logging
from mcp.schemas import (
ANALYZE_DECISION_IMPACT,
FIND_PRECEDENTS,
GET_CAUSAL_CHAIN,
QUERY_DECISIONS,
RECORD_DECISION,
)
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.decisions")
def handle_record_decision(args: dict) -> dict:
"""Record a decision with full context into the knowledge graph."""
required = ["category", "scenario", "reasoning", "outcome", "confidence"]
missing = [f for f in required if f not in args]
if missing:
return {"error": f"Missing required fields: {', '.join(missing)}"}
try:
graph = get_graph()
decision_id = graph.record_decision(
category=str(args["category"]),
scenario=str(args["scenario"]),
reasoning=str(args["reasoning"]),
outcome=str(args["outcome"]),
confidence=float(args["confidence"]),
entities=args.get("entities", []),
decision_maker=args.get("decision_maker", "mcp_client"),
valid_from=args.get("valid_from"),
valid_until=args.get("valid_until"),
)
return {
"decision_id": decision_id,
"status": "recorded",
"category": args["category"],
"outcome": args["outcome"],
}
except Exception as exc:
log.exception("record_decision failed")
return {"error": str(exc)}
def handle_query_decisions(args: dict) -> dict:
"""Query recorded decisions by natural language or structured filters."""
query = args.get("query", "").strip()
category = args.get("category", "").strip()
outcome_filter = args.get("outcome", "").strip()
limit = int(args.get("limit", 10))
try:
graph = get_graph()
if query:
results = graph.find_similar_decisions(query, max_results=limit)
decisions = results if isinstance(results, list) else list(results)
else:
nodes = graph.find_nodes(node_type="decision")
decisions = list(nodes)[:limit * 5] # over-fetch for filtering
if category:
decisions = [d for d in decisions if d.get("category") == category]
if outcome_filter:
decisions = [d for d in decisions if d.get("outcome") == outcome_filter]
decisions = decisions[:limit]
return {"decisions": decisions, "count": len(decisions)}
except Exception as exc:
log.exception("query_decisions failed")
return {"error": str(exc), "decisions": []}
def handle_find_precedents(args: dict) -> dict:
"""Find past decisions similar to a given scenario using hybrid similarity search."""
scenario = args.get("scenario", "").strip()
if not scenario:
return {"error": "scenario is required", "precedents": []}
max_results = int(args.get("max_results", 5))
try:
graph = get_graph()
precedents = graph.find_similar_decisions(scenario, max_results=max_results)
results = precedents if isinstance(precedents, list) else list(precedents)
return {"precedents": results, "count": len(results)}
except Exception as exc:
log.exception("find_precedents failed")
return {"error": str(exc), "precedents": []}
def handle_get_causal_chain(args: dict) -> dict:
"""Trace the upstream or downstream causal chain from a decision."""
decision_id = args.get("decision_id", "").strip()
if not decision_id:
return {"error": "decision_id is required", "chain": []}
direction = args.get("direction", "downstream")
max_depth = int(args.get("max_depth", 5))
try:
graph = get_graph()
try:
from semantica.context.causal_analyzer import CausalChainAnalyzer
analyzer = CausalChainAnalyzer(graph_store=graph)
chain = analyzer.get_causal_chain(
decision_id, direction=direction, max_depth=max_depth
)
except (ImportError, AttributeError):
chain = graph.get_causal_chain(decision_id) if hasattr(graph, "get_causal_chain") else []
result = chain if isinstance(chain, list) else list(chain)
return {"chain": result, "count": len(result), "direction": direction}
except Exception as exc:
log.exception("get_causal_chain failed")
return {"error": str(exc), "chain": []}
def handle_analyze_decision_impact(args: dict) -> dict:
"""Analyse the downstream impact of a decision on the graph."""
decision_id = args.get("decision_id", "").strip()
if not decision_id:
return {"error": "decision_id is required"}
try:
graph = get_graph()
if hasattr(graph, "analyze_decision_impact"):
impact = graph.analyze_decision_impact(decision_id)
elif hasattr(graph, "analyze_decision_influence"):
impact = graph.analyze_decision_influence(decision_id)
else:
impact = {"message": "impact analysis not available on this graph instance"}
return {"decision_id": decision_id, "impact": impact}
except Exception as exc:
log.exception("analyze_decision_impact failed")
return {"error": str(exc)}
DECISION_TOOLS = [
{
"name": "record_decision",
"description": "Record a decision with full context, causal links, and metadata into the Semantica knowledge graph.",
"inputSchema": RECORD_DECISION,
"_handler": handle_record_decision,
},
{
"name": "query_decisions",
"description": "Query recorded decisions by natural language, category, or outcome filter.",
"inputSchema": QUERY_DECISIONS,
"_handler": handle_query_decisions,
},
{
"name": "find_precedents",
"description": "Find past decisions similar to a given scenario using hybrid similarity search.",
"inputSchema": FIND_PRECEDENTS,
"_handler": handle_find_precedents,
},
{
"name": "get_causal_chain",
"description": "Trace the causal chain upstream or downstream from a recorded decision.",
"inputSchema": GET_CAUSAL_CHAIN,
"_handler": handle_get_causal_chain,
},
{
"name": "analyze_decision_impact",
"description": "Analyse the downstream impact and influence of a decision across the knowledge graph.",
"inputSchema": ANALYZE_DECISION_IMPACT,
"_handler": handle_analyze_decision_impact,
},
]
+150
View File
@@ -0,0 +1,150 @@
"""
Export tools — graph export (JSON/RDF/CSV/GraphML/Parquet) and provenance.
"""
from __future__ import annotations
import logging
from mcp.schemas import EXPORT_GRAPH, GET_PROVENANCE
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.export")
_FORMAT_ALIASES: dict[str, str] = {
"ttl": "turtle",
"turtle": "turtle",
"nt": "nt",
"xml": "xml",
"json-ld": "json-ld",
"jsonld": "json-ld",
}
def handle_export_graph(args: dict) -> dict:
"""Export the knowledge graph to a structured format."""
fmt = str(args.get("format", "json")).lower().strip()
include_metadata = bool(args.get("include_metadata", True))
try:
graph = get_graph()
if fmt == "json":
nodes = list(graph.find_nodes())
edges: list = []
if hasattr(graph, "find_edges"):
try:
edges = list(graph.find_edges())
except Exception as exc:
log.debug("Failed to collect edges during JSON export; continuing with empty edges: %s", exc)
payload: dict = {"nodes": nodes, "edges": edges}
if include_metadata:
payload["meta"] = {
"node_count": len(nodes),
"edge_count": len(edges),
"format": "json",
}
return {"format": "json", "data": payload}
if fmt in ("csv",):
nodes = list(graph.find_nodes())
rows = []
for n in nodes:
rows.append(",".join([
str(n.get("id", "")),
str(n.get("label", "")),
str(n.get("type", "")),
]))
header = "id,label,type"
return {"format": "csv", "data": header + "\n" + "\n".join(rows)}
if fmt in ("graphml",):
try:
from semantica.export import GraphMLExporter
exporter = GraphMLExporter()
data = exporter.export(graph)
return {"format": "graphml", "data": data}
except Exception as exc:
return {"error": f"GraphML export failed: {exc}"}
if fmt in ("parquet",):
try:
from semantica.export import ParquetExporter
exporter = ParquetExporter()
data = exporter.export(graph, include_metadata)
return {"format": "parquet", "data": str(data)}
except Exception as exc:
return {"error": f"Parquet export failed: {exc}"}
# RDF formats
rdf_fmt = _FORMAT_ALIASES.get(fmt)
if rdf_fmt:
try:
from semantica.export import RDFExporter
rdf_str = RDFExporter().export_to_rdf(graph, format=rdf_fmt)
return {"format": rdf_fmt, "data": rdf_str}
except Exception as exc:
return {"error": f"RDF export failed: {exc}"}
return {"error": f"Unsupported format '{fmt}'. Supported: json, csv, graphml, parquet, turtle, nt, xml, json-ld"}
except Exception as exc:
log.exception("export_graph failed")
return {"error": str(exc)}
def handle_get_provenance(args: dict) -> dict:
"""Retrieve the provenance / audit history for a node."""
node_id = args.get("node_id", "").strip()
if not node_id:
return {"error": "node_id is required", "provenance": []}
include_metadata = bool(args.get("include_metadata", True))
try:
graph = get_graph()
# Try ProvenanceTracker first
try:
from semantica.kg import ProvenanceTracker
tracker = ProvenanceTracker()
records = tracker.get_provenance(node_id)
result = records if isinstance(records, list) else list(records)
except (ImportError, AttributeError):
# Fallback: look for provenance on the node itself
nodes = list(graph.find_nodes())
matched = [n for n in nodes if n.get("id") == node_id]
if matched:
node = matched[0]
prov = node.get("provenance") or node.get("source") or node.get("metadata", {})
result = [prov] if prov else []
else:
result = []
payload: dict = {"node_id": node_id, "provenance": result, "count": len(result)}
if include_metadata and result:
payload["sources"] = list({
str(r.get("source", r.get("origin", "")))
for r in result
if isinstance(r, dict)
})
return payload
except Exception as exc:
log.exception("get_provenance failed")
return {"error": str(exc), "provenance": []}
EXPORT_TOOLS = [
{
"name": "export_graph",
"description": (
"Export the Semantica knowledge graph to JSON, CSV, GraphML, Parquet, "
"Turtle (RDF), N-Triples, RDF/XML, or JSON-LD."
),
"inputSchema": EXPORT_GRAPH,
"_handler": handle_export_graph,
},
{
"name": "get_provenance",
"description": "Retrieve the provenance and audit history for a specific node in the knowledge graph.",
"inputSchema": GET_PROVENANCE,
"_handler": handle_get_provenance,
},
]
+168
View File
@@ -0,0 +1,168 @@
"""
Extraction tools — NER, relation extraction, event detection, triplets.
"""
from __future__ import annotations
import logging
from typing import Any
from mcp.schemas import EXTRACT_ALL, EXTRACT_ENTITIES, EXTRACT_RELATIONS
log = logging.getLogger("semantica.mcp.tools.extraction")
def _clear_cache() -> None:
try:
from semantica.semantic_extract.cache import _result_cache
_result_cache.clear()
except Exception:
log.debug("Could not clear semantic_extract cache; continuing", exc_info=True)
def handle_extract_entities(args: dict) -> dict:
"""Extract named entities from text using Semantica NER."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required", "entities": []}
_clear_cache()
try:
from semantica.semantic_extract import NamedEntityRecognizer
entities = NamedEntityRecognizer().extract(text) or []
return {
"entities": [
{
"label": getattr(e, "label", str(e)),
"type": getattr(e, "type", None),
"start": getattr(e, "start", None),
"end": getattr(e, "end", None),
"confidence": getattr(e, "confidence", None),
}
for e in entities
],
"count": len(entities),
}
except Exception as exc:
log.exception("extract_entities failed")
return {"error": str(exc), "entities": []}
def handle_extract_relations(args: dict) -> dict:
"""Extract relations and triplets from text."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required", "relations": [], "triplets": []}
_clear_cache()
try:
from semantica.semantic_extract import NamedEntityRecognizer, RelationExtractor, TripletExtractor
entities = NamedEntityRecognizer().extract(text) or []
relations = RelationExtractor().extract(text, entities) or []
triplets = TripletExtractor().extract(text) or []
return {
"relations": [
{
"source": getattr(r, "source", None),
"type": getattr(r, "type", None),
"target": getattr(r, "target", None),
"confidence": getattr(r, "confidence", None),
}
for r in relations
],
"triplets": [
{
"subject": getattr(t, "subject", None),
"predicate": getattr(t, "predicate", None),
"object": getattr(t, "object", None),
}
for t in triplets
],
"relation_count": len(relations),
"triplet_count": len(triplets),
}
except Exception as exc:
log.exception("extract_relations failed")
return {"error": str(exc), "relations": [], "triplets": []}
def handle_extract_all(args: dict) -> dict:
"""Run the full extraction pipeline: NER + relations + events + triplets."""
text = args.get("text", "").strip()
if not text:
return {"error": "text is required"}
include_events = args.get("include_events", True)
include_triplets = args.get("include_triplets", True)
_clear_cache()
result: dict[str, Any] = {}
try:
from semantica.semantic_extract import (
CoreferenceResolver,
EventDetector,
NamedEntityRecognizer,
RelationExtractor,
TripletExtractor,
)
entities = NamedEntityRecognizer().extract(text) or []
result["entities"] = [
{"label": getattr(e, "label", str(e)), "type": getattr(e, "type", None)}
for e in entities
]
resolved = CoreferenceResolver().resolve(text)
relations = RelationExtractor().extract(resolved, entities) or []
result["relations"] = [
{"source": getattr(r, "source", None),
"type": getattr(r, "type", None),
"target": getattr(r, "target", None)}
for r in relations
]
if include_events:
events = EventDetector().extract(text) or []
result["events"] = [
{"type": getattr(ev, "type", None),
"trigger": getattr(ev, "trigger", str(ev))}
for ev in events
]
if include_triplets:
triplets = TripletExtractor().extract(resolved) or []
result["triplets"] = [
{"subject": getattr(t, "subject", None),
"predicate": getattr(t, "predicate", None),
"object": getattr(t, "object", None)}
for t in triplets
]
result["summary"] = {
"entities": len(result.get("entities", [])),
"relations": len(result.get("relations", [])),
"events": len(result.get("events", [])),
"triplets": len(result.get("triplets", [])),
}
return result
except Exception as exc:
log.exception("extract_all failed")
return {"error": str(exc)}
EXTRACTION_TOOLS = [
{
"name": "extract_entities",
"description": "Extract named entities (people, places, organisations, concepts) from text.",
"inputSchema": EXTRACT_ENTITIES,
"_handler": handle_extract_entities,
},
{
"name": "extract_relations",
"description": "Extract relations and (subject, predicate, object) triplets from text.",
"inputSchema": EXTRACT_RELATIONS,
"_handler": handle_extract_relations,
},
{
"name": "extract_all",
"description": "Run the full Semantica extraction pipeline: NER, coreference resolution, relation extraction, event detection, and triplet generation.",
"inputSchema": EXTRACT_ALL,
"_handler": handle_extract_all,
},
]
+187
View File
@@ -0,0 +1,187 @@
"""
Graph tools — add entities/relationships, search, analytics, summary.
"""
from __future__ import annotations
import logging
from mcp.schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
from mcp.session import get_graph
log = logging.getLogger("semantica.mcp.tools.graph")
def handle_add_entity(args: dict) -> dict:
"""Add a node/entity to the Semantica knowledge graph."""
node_id = args.get("id", "").strip()
if not node_id:
return {"error": "id is required"}
try:
graph = get_graph()
graph.add_node(
node_id=node_id,
label=args.get("label", node_id),
node_type=args.get("type", "Entity"),
metadata=args.get("metadata", {}),
)
return {"status": "added", "id": node_id, "type": args.get("type", "Entity")}
except Exception as exc:
log.exception("add_entity failed")
return {"error": str(exc)}
def handle_add_relationship(args: dict) -> dict:
"""Add a directed relationship (edge) between two entities."""
source = args.get("source", "").strip()
target = args.get("target", "").strip()
if not source or not target:
return {"error": "source and target are required"}
rel_type = args.get("type", "RELATED_TO")
try:
graph = get_graph()
graph.add_edge(
source_id=source,
target_id=target,
edge_type=rel_type,
metadata=args.get("metadata", {}),
)
return {"status": "added", "source": source, "target": target, "type": rel_type}
except Exception as exc:
log.exception("add_relationship failed")
return {"error": str(exc)}
def handle_search_graph(args: dict) -> dict:
"""Search nodes in the knowledge graph by label or metadata."""
query = args.get("query", "").strip()
if not query:
return {"error": "query is required", "results": []}
node_type = args.get("node_type", "").strip() or None
limit = int(args.get("limit", 20))
try:
graph = get_graph()
if node_type:
nodes = list(graph.find_nodes(node_type=node_type))
else:
nodes = list(graph.find_nodes())
q = query.lower()
matched = [
n for n in nodes
if q in str(n.get("label", "")).lower()
or q in str(n.get("id", "")).lower()
][:limit]
return {"results": matched, "count": len(matched), "query": query}
except Exception as exc:
log.exception("search_graph failed")
return {"error": str(exc), "results": []}
def handle_get_graph_summary(args: dict) -> dict: # noqa: ARG001
"""Return a high-level summary of the current knowledge graph."""
try:
graph = get_graph()
all_nodes = list(graph.find_nodes())
decisions = [n for n in all_nodes if n.get("type") in ("decision", "Decision")]
node_types: dict[str, int] = {}
for n in all_nodes:
t = str(n.get("type", "Unknown"))
node_types[t] = node_types.get(t, 0) + 1
edge_count = 0
if hasattr(graph, "edge_count"):
try:
edge_count = graph.edge_count()
except Exception:
log.exception("graph.edge_count failed; defaulting edge_count to 0")
return {
"node_count": len(all_nodes),
"edge_count": edge_count,
"decision_count": len(decisions),
"node_types": node_types,
"graph_ready": True,
}
except Exception as exc:
log.exception("get_graph_summary failed")
return {"error": str(exc), "graph_ready": False}
def handle_get_graph_analytics(args: dict) -> dict:
"""Compute centrality, community detection, and other graph metrics."""
requested = args.get("metrics", ["all"])
top_n = int(args.get("top_n", 10))
compute_all = "all" in requested
result: dict = {}
try:
graph = get_graph()
from semantica.kg import CentralityCalculator, CommunityDetector
if compute_all or "pagerank" in requested:
try:
pr = CentralityCalculator().calculate_pagerank(graph)
items = pr.items() if hasattr(pr, "items") else []
result["pagerank"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["pagerank_error"] = str(exc)
if compute_all or "betweenness" in requested:
try:
bc = CentralityCalculator().calculate_betweenness_centrality(graph)
items = bc.items() if hasattr(bc, "items") else []
result["betweenness"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["betweenness_error"] = str(exc)
if compute_all or "communities" in requested:
try:
comms = CommunityDetector().detect_communities(graph)
result["community_count"] = len(comms) if isinstance(comms, (list, dict)) else 0
result["communities"] = comms if isinstance(comms, list) else []
except Exception as exc:
result["communities_error"] = str(exc)
if compute_all or "degree" in requested:
try:
deg = CentralityCalculator().calculate_degree_centrality(graph)
items = deg.items() if hasattr(deg, "items") else []
result["degree"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
except Exception as exc:
result["degree_error"] = str(exc)
return result
except Exception as exc:
log.exception("get_graph_analytics failed")
return {"error": str(exc)}
GRAPH_TOOLS = [
{
"name": "add_entity",
"description": "Add a node or entity (person, place, concept, organisation) to the knowledge graph.",
"inputSchema": ADD_ENTITY,
"_handler": handle_add_entity,
},
{
"name": "add_relationship",
"description": "Add a directed relationship (edge) between two entities in the knowledge graph.",
"inputSchema": ADD_RELATIONSHIP,
"_handler": handle_add_relationship,
},
{
"name": "search_graph",
"description": "Search nodes in the knowledge graph by label or ID substring.",
"inputSchema": SEARCH_GRAPH,
"_handler": handle_search_graph,
},
{
"name": "get_graph_summary",
"description": "Return a high-level summary of the knowledge graph: node count, edge count, decision count, node type breakdown.",
"inputSchema": EMPTY,
"_handler": handle_get_graph_summary,
},
{
"name": "get_graph_analytics",
"description": "Compute PageRank centrality, betweenness centrality, degree centrality, and community detection over the knowledge graph.",
"inputSchema": GET_ANALYTICS,
"_handler": handle_get_graph_analytics,
},
]
+73
View File
@@ -0,0 +1,73 @@
"""
Reasoning tools — forward chaining, abductive reasoning.
"""
from __future__ import annotations
import logging
from mcp.schemas import ABDUCTIVE_REASONING, RUN_REASONING
log = logging.getLogger("semantica.mcp.tools.reasoning")
def handle_run_reasoning(args: dict) -> dict:
"""Run forward-chaining IF/THEN rules over facts to derive new knowledge."""
facts = args.get("facts", [])
rules = args.get("rules", [])
if not facts:
return {"error": "facts list is required", "derived_facts": []}
if not rules:
return {"error": "rules list is required", "derived_facts": []}
try:
from semantica.reasoning import Reasoner
reasoner = Reasoner()
for rule in rules:
reasoner.add_rule(str(rule))
derived = reasoner.infer_facts(facts)
result = derived if isinstance(derived, list) else list(derived)
return {
"derived_facts": result,
"count": len(result),
"input_facts": len(facts),
"rules_applied": len(rules),
}
except Exception as exc:
log.exception("run_reasoning failed")
return {"error": str(exc), "derived_facts": []}
def handle_abductive_reasoning(args: dict) -> dict:
"""Generate plausible hypotheses that explain a set of observations."""
observations = args.get("observations", [])
if not observations:
return {"error": "observations list is required", "hypotheses": []}
max_hypotheses = int(args.get("max_hypotheses", 5))
try:
from semantica.reasoning import AbductiveReasoner
reasoner = AbductiveReasoner()
hypotheses = reasoner.generate_hypotheses(observations)
result = hypotheses if isinstance(hypotheses, list) else list(hypotheses)
return {
"hypotheses": result[:max_hypotheses],
"count": min(len(result), max_hypotheses),
}
except Exception as exc:
log.exception("abductive_reasoning failed")
return {"error": str(exc), "hypotheses": []}
REASONING_TOOLS = [
{
"name": "run_reasoning",
"description": "Run forward-chaining IF/THEN rules over a set of facts to derive new facts. E.g. facts=['Person(John)'], rules=['IF Person(?x) THEN Mortal(?x)'] → derives 'Mortal(John)'.",
"inputSchema": RUN_REASONING,
"_handler": handle_run_reasoning,
},
{
"name": "abductive_reasoning",
"description": "Generate plausible hypotheses that best explain a set of observed facts.",
"inputSchema": ABDUCTIVE_REASONING,
"_handler": handle_abductive_reasoning,
},
]
+130 -10
View File
@@ -2,13 +2,21 @@
Semantica ships a shared plugin bundle under `plugins/` with skills, agents, and hooks for knowledge graphs, context graphs, decision intelligence, reasoning, explainability, provenance, ontology, and export workflows.
This README is for community users who want to install or reuse the plugin package across Claude, Cursor, and Codex.
This README covers installation across every supported platform.
## Supported Platforms
- Claude Code
- Cursor
- Codex
| Platform | Method | Config file |
|---|---|---|
| Claude Code | Native plugin bundle | `plugins/.claude-plugin/plugin.json` |
| Cursor | Native plugin bundle | `plugins/.cursor-plugin/plugin.json` |
| Codex CLI | Native plugin bundle | `plugins/.codex-plugin/plugin.json` |
| Windsurf | MCP server + plugin bundle | `plugins/.windsurf-plugin/plugin.json` |
| Cline (VS Code) | MCP server + plugin bundle | `plugins/.cline-plugin/plugin.json` |
| Continue | MCP server | `plugins/.continue-plugin/plugin.json` |
| VS Code | MCP server | `plugins/.vscode-plugin/plugin.json` |
| Claude Desktop | MCP server | — (see MCP section below) |
| Any MCP client | MCP server | `python -m semantica.mcp_server` |
## Prerequisites
@@ -23,12 +31,16 @@ cd semantica
```text
plugins/
skills/
agents/
hooks/
.claude-plugin/
.cursor-plugin/
.codex-plugin/
skills/ ← 17 domain skills
agents/ ← 3 specialized agents
hooks/ ← hooks.json
.claude-plugin/ ← Claude Code manifest
.cursor-plugin/ ← Cursor manifest
.codex-plugin/ ← Codex CLI manifest
.windsurf-plugin/← Windsurf manifest + MCP config
.cline-plugin/ ← Cline manifest + MCP config
.continue-plugin/← Continue manifest + MCP config
.vscode-plugin/ ← VS Code manifest + MCP config
```
## Plugin Contents
@@ -128,6 +140,114 @@ After installing on any platform, these are good smoke tests:
4. `/semantica:explain decision <decision_id>`
5. `/semantica:validate graph`
## MCP Server (Windsurf · Cline · Continue · VS Code · Claude Desktop · Any tool)
Semantica includes a full MCP server (`semantica/mcp_server.py`) that exposes 12 tools and 3 resources over stdio — compatible with any MCP-aware tool.
### Start the server
```bash
python -m semantica.mcp_server
```
### Configure in your tool
**Claude Desktop**`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
```
**Windsurf**`~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
```
**Cline** — Cline MCP settings panel → Add server:
```json
{
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
```
**Continue**`~/.continue/config.json`:
```json
{
"mcpServers": [
{
"name": "semantica",
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
]
}
```
**VS Code**`settings.json`:
```json
{
"mcp.servers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
```
### Available MCP tools
| Tool | Description |
|---|---|
| `extract_entities` | Named entity recognition from text |
| `extract_relations` | Relation and triplet extraction from text |
| `record_decision` | Record a decision with full context and metadata |
| `query_decisions` | Query recorded decisions by natural language or category |
| `find_precedents` | Find past decisions similar to a scenario |
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
| `add_entity` | Add a node/entity to the knowledge graph |
| `add_relationship` | Add a directed edge between two entities |
| `run_reasoning` | Run IF/THEN rules over facts to derive new facts |
| `get_graph_analytics` | PageRank centrality and community detection |
| `export_graph` | Export graph as Turtle, JSON-LD, N-Triples, or JSON |
| `get_graph_summary` | Node count, decision count, graph status |
### Available MCP resources
| URI | Description |
|---|---|
| `semantica://graph/summary` | High-level graph statistics |
| `semantica://decisions/list` | All recorded decisions |
| `semantica://schema/info` | Server info and capability list |
### Environment variables
| Variable | Description |
|---|---|
| `SEMANTICA_KG_PATH` | Path to a persisted graph to load on start |
| `SEMANTICA_LOG_LEVEL` | Log level: DEBUG, INFO, WARNING (default: WARNING) |
## Community Notes
- Keep plugin name/version/keywords updated in each manifest before publishing.
+24
View File
@@ -0,0 +1,24 @@
# Semantica — Cline Plugin
Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
## MCP Server Setup (recommended)
In Cline settings, add a new MCP server:
```json
{
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"],
"env": {}
}
}
```
Cline will discover all 12 Semantica tools automatically on connection.
## Requirements
- Python 3.8+
- `pip install semantica`
+17
View File
@@ -0,0 +1,17 @@
{
"name": "semantica-cline",
"plugins": [
{
"name": "semantica",
"description": "Semantica plugin for Cline: knowledge graph skills, reasoning, extraction, and visualization.",
"source": "./",
"category": "Productivity",
"tags": [
"knowledge-graph",
"reasoning",
"semantica",
"cline"
]
}
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "semantica-cline",
"displayName": "Semantica Cline Plugin",
"description": "Semantica plugin for Cline: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization.",
"version": "0.1.0",
"author": {
"name": "Semantica Contributors"
},
"homepage": "https://github.com/Hawksight-AI/semantica",
"repository": "https://github.com/Hawksight-AI/semantica",
"license": "MIT",
"keywords": [
"semantica",
"knowledge graph",
"cline",
"context graphs",
"decision intelligence",
"explainability",
"causal analysis",
"provenance",
"ontology",
"graph analytics",
"semantic extraction",
"visualization",
"reasoning",
"mcp"
],
"skills": "../skills",
"agents": "../agents",
"hooks": "../hooks/hooks.json",
"mcp": {
"server": "python -m semantica.mcp_server",
"transport": "stdio"
}
}
+26
View File
@@ -0,0 +1,26 @@
# Semantica — Continue Plugin
Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
## MCP Server Setup
Add to `~/.continue/config.json`:
```json
{
"mcpServers": [
{
"name": "semantica",
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
]
}
```
Continue will show all Semantica tools in the `@semantica` context provider dropdown.
## Requirements
- Python 3.8+
- `pip install semantica`
+17
View File
@@ -0,0 +1,17 @@
{
"name": "semantica-continue",
"plugins": [
{
"name": "semantica",
"description": "Semantica plugin for Continue.dev: knowledge graph context provider, reasoning, and extraction.",
"source": "./",
"category": "Productivity",
"tags": [
"knowledge-graph",
"reasoning",
"semantica",
"continue"
]
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "semantica-continue",
"displayName": "Semantica Continue Plugin",
"description": "Semantica plugin for Continue.dev: knowledge graph context provider, decision intelligence, reasoning, and semantic extraction.",
"version": "0.1.0",
"author": {
"name": "Semantica Contributors"
},
"homepage": "https://github.com/Hawksight-AI/semantica",
"repository": "https://github.com/Hawksight-AI/semantica",
"license": "MIT",
"keywords": [
"semantica",
"knowledge graph",
"continue",
"context provider",
"decision intelligence",
"explainability",
"causal analysis",
"provenance",
"ontology",
"semantic extraction",
"reasoning",
"mcp"
],
"skills": "../skills",
"agents": "../agents",
"hooks": "../hooks/hooks.json",
"mcp": {
"server": "python -m semantica.mcp_server",
"transport": "stdio"
}
}

Some files were not shown because too many files have changed in this diff Show More