Compare commits

...
Author SHA1 Message Date
KaifAhmad1andClaude Sonnet 4.6 d72afe6217 feat(integrations): add OpenClaw plugin and integration module
- Add integrations/openclaw/ with OpenClawKGTool (REST) and
  OpenClawMCPConfig (mcporter.json generator)
- Add plugins/.openclaw-plugin/ bundle (plugin.json, marketplace.json,
  README) with MCP + native tool support
- Add OpenClaw badge to README header
- Reorganize "Works With Every AI Tool" table into labeled groups:
  Native Plugin Bundle, MCP Server + Plugin, MCP Server, REST API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:14:50 +05:30
Mohd Kaif a1478af9c4 Merge pull request #453 from Hawksight-AI/explorer
feat(explorer): add Semantica Knowledge Explorer UI with full feature…
2026-04-14 11:43:57 +05:30
Mohd Kaif ee4d6a9188 Update CHANGELOG with recent changes and fixes
Updated CHANGELOG to reflect recent fixes and security enhancements, including improvements to KGVisualizer and vulnerability fixes.
2026-04-14 11:21:27 +05:30
Mohd Kaif 2d00257ae5 Merge pull request #459 from Hawksight-AI/visualization
fix(visualization): Accept KnowledgeGraph objects in all `visualize_*` methods
2026-04-14 11:18:41 +05:30
KaifAhmad1andClaude Sonnet 4.6 e78ad7f819 fix(visualization): accept KnowledgeGraph objects in all visualize_* methods (closes #458)
KGVisualizer.visualize_network() (and sibling methods) only accepted a raw
dict. Passing a KnowledgeGraph object — the natural output of
GraphBuilder.build() — silently returned without rendering.

Added _normalize_graph() which duck-types the input: dicts pass through
unchanged; any object exposing .entities / .relationships attributes is
converted to the canonical dict form; anything else raises a clear
ProcessingError naming the offending type.

_normalize_graph() is called as the first statement in visualize_network(),
visualize_communities(), visualize_centrality(), visualize_entity_types(),
and visualize_relationship_matrix().

Also adds 21 tests in tests/visualization/test_kg_visualizer_normalize_graph.py
covering the helper directly, the end-to-end regression for #458, and
a guard that every public method routes through _normalize_graph.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:04:25 +05:30
Mohd KaifandClaude Sonnet 4.6 fdb347fe8a feat(cookbook): add Datalog-style reasoning end-to-end notebook (#457)
End-to-end example using DatalogReasoner, GraphBuilder, ContextGraph,
GraphAnalyzer, ExplanationGenerator, DatalogFact, and DatalogRule.
Covers ancestor query, KG dependency analysis, RBAC policy, and org hierarchy.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:43:04 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 1cc2f6b93a Potential fix for pull request finding 'Wrong number of arguments in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:52:23 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 9e26d96b3c Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:50 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 7267425eb5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:34 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> d9cf7b0088 Potential fix for pull request finding 'Unused global variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:14 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 09666806da Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:55 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 9daddd8186 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:40 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> dc8d7ddb03 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:44 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> b34634c8b5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:28 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> ee93c4bbe1 Potential fix for pull request finding 'Wrong name for an argument in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:13 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> e4425818e4 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:43:56 +05:30
KaifAhmad1andClaude Sonnet 4.6 7b31304e1e feat(mcp): add modular MCP server package at repo root
Adds a fully self-contained `mcp/` package that exposes Semantica as a
Model Context Protocol server over stdio (JSON-RPC 2.0).

17 tools across 5 domains:
- Extraction: extract_entities, extract_relations, extract_all
- Decision intelligence: record_decision, query_decisions, find_precedents,
  get_causal_chain, analyze_decision_impact
- Knowledge graph: add_entity, add_relationship, search_graph,
  get_graph_summary, get_graph_analytics
- Reasoning: run_reasoning, abductive_reasoning
- Export & provenance: export_graph (JSON/CSV/GraphML/Parquet/RDF), get_provenance

4 resources: semantica://graph/summary, semantica://decisions/list,
semantica://schema/info, semantica://ontology/schema

Package layout:
  mcp/__init__.py + __main__.py  — entry points (python -m mcp)
  mcp/server.py                  — SemanticaMCPServer + stdio event loop
  mcp/session.py                 — lazy ContextGraph singleton
  mcp/schemas.py                 — JSON Schema for all 17 tool inputs
  mcp/tools/{extraction,decisions,graph,reasoning,export}.py
  mcp/resources/registry.py      — URI → handler map
  mcp/README.md                  — per-tool setup (Claude Code, Cursor, Windsurf,
                                   Cline, Continue, VS Code, Amazon Q)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:38:23 +05:30
KaifAhmad1andClaude Sonnet 4.6 ab93ec3e8f feat(plugins): add MCP server + 4 new plugin bundles (Windsurf, Cline, Continue, VS Code)
MCP Server (semantica/mcp_server.py):
- Full stdio-based MCP server compatible with Claude Desktop, Windsurf,
  Cline, Continue, VS Code, Roo Code, and any MCP-aware tool
- 12 tools: 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
- Lazy graph session with optional SEMANTICA_KG_PATH env var
- JSON-RPC 2.0 over stdin/stdout; run with: python -m semantica.mcp_server

New plugin bundles (each: plugin.json + marketplace.json + README.md):
- plugins/.windsurf-plugin/ — Windsurf MCP config + 17 skills + 3 agents
- plugins/.cline-plugin/    — Cline MCP config + 17 skills + 3 agents
- plugins/.continue-plugin/ — Continue MCP config + 17 skills + 3 agents
- plugins/.vscode-plugin/   — VS Code MCP config + 17 skills + 3 agents

Updated plugins/.claude-plugin/README.md:
- Platform support table expanded to 9 tools
- Full MCP server section: per-tool config snippets for Claude Desktop,
  Windsurf, Cline, Continue, VS Code; tool/resource reference tables;
  environment variables

Updated README.md:
- Hero line updated to mention MCP server
- Visual grid: Windsurf/VS Code/Cline/Continue → 'MCP server + plugin';
  Claude Desktop → 'MCP server'
- Plugin Bundles section: expanded table listing all 7 bundles with dirs
- New MCP Server section with quick-start snippet and tool/resource list
- Detailed integrations table: corrected connection types and config paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:59:45 +05:30
KaifAhmad1andClaude Sonnet 4.6 f2eb3e1608 docs(readme): accurate plugin/integration/API docs based on actual code
Tools grid:
- Claude Code/Cursor/Codex: 'Native plugin' (plugins/ dirs exist in repo)
- All other tools: 'REST API' (no MCP server impl in codebase — Semantica
  has an MCP CLIENT for ingesting from MCP servers, not an MCP server)
- Codex CLI added back (has real plugin bundle at plugins/.codex-plugin/)

Plugin Bundles section:
- Full table of all 17 skills with descriptions matching SKILL.md files
- Full table of all 3 agents (kg-assistant, decision-advisor, explainability)
- Hooks entry referencing plugins/hooks/hooks.json

MCP Client section:
- Correct framing: MCPClient in semantica/ingest/mcp_client.py pulls
  data FROM MCP servers into KG (not an MCP server itself)
- Code snippet + supported schemes

REST API Server section:
- Lists all 10 route modules from semantica/explorer/routes/ with paths
- WebSocket /ws endpoint
- Health check

Agno integration section:
- Expanded to table showing all 5 actual files in integrations/agno/
  with class names and descriptions matching source code

AI Coding Tools table:
- Corrected connection types and setup notes to match actual code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:51:34 +05:30
KaifAhmad1andClaude Sonnet 4.6 898a660ca7 docs(readme): add explicit integrations table for all 16 AI tools + expand sections
- Add 'AI Coding Tools & IDEs' table under Integrations listing every
  tool from the visual grid with connection type and setup note:
  Claude Code, Cursor, Windsurf, Claude Desktop, VS Code, GitHub
  Copilot, Cline, Roo Code, Continue, Goose, Kilo Code, Aider,
  Amazon Q, Zed, Claude SDK, REST API (109 endpoints)
- Add Neo4j to Graph Databases list (was in modules but missing here)
- Add Email and Repository ingestors to Data Sources
- Expand LLM Providers: add Groq, HuggingFace, Ollama entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:25:02 +05:30
KaifAhmad1andClaude Sonnet 4.6 fb1e6a6d6e docs(readme): revise tools grid with accurate popular integrations
AI tools grid (removed Gemini CLI, Codex CLI; added VS Code, GitHub
Copilot, Continue, Amazon Q, Zed — all confirmed MCP-supporting tools
with significant user bases in 2026):
Row 1: Claude Code, Cursor, Windsurf, Claude Desktop, VS Code,
        GitHub Copilot, Cline, Roo Code
Row 2: Continue, Goose, Kilo Code, Aider, Amazon Q, Zed,
        Claude SDK, Any agent REST API

Agentic frameworks grid (added LangGraph and OpenAI Agents SDK, expanded
to 8 entries): Agno, LangChain, LangGraph, LlamaIndex, AutoGen, CrewAI,
OpenAI Agents SDK, Google ADK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:11:00 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8d0dce13c5 ci(deps): bump softprops/action-gh-release from 1 to 3 (#455)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:58:50 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 61c8435bd7 ci(deps): bump actions/github-script from 8 to 9 (#454)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:21:15 +05:30
KaifAhmad1andClaude Sonnet 4.6 0d7b9ca1df docs(readme): add Semantica Knowledge Explorer section to main README
- New '🖥️ Semantica Knowledge Explorer' section placed after Plugins,
  with a workspace-tab table (Graph, Timeline, Decisions, Registry,
  Entity Resolution, KG Overview, Ontology), a 4-line quick-start
  snippet, requirements line, and a pointer to explorer/README.md
- Added explorer/ row to the detailed Modules table with a link
- Added explorer/ bullet to the condensed Modules list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:59:21 +05:30
KaifAhmad1andClaude Sonnet 4.6 aca15d3694 docs(explorer): replace default Vite README with full local setup guide
Covers requirements (Node 18+/Python 3.8+), backend start command,
npm install, dev server, all 6 workspace tabs, available npm scripts,
API/WebSocket proxy table, production build, troubleshooting steps,
and tech stack summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:30:27 +05:30
KaifAhmad1andClaude Sonnet 4.6 670027fd22 fix(explorer): resolve 3 code-review bugs in GraphWorkspace, DecisionWorkspace, and index.css
- GraphWorkspace: set isRunningPredictions=true before link-prediction fetch
  and false in finally block; pass isRunningPredictions prop to
  LazyGraphInspectorPanel so the inspector button disables and shows a
  spinner during the request (was declared but never wired — broke
  noUnusedLocals TypeScript build)

- DecisionWorkspace: add AbortController to the /api/decisions useEffect
  so the fetch is cancelled on unmount; add per-call AbortController to
  handleSelectDecision for /api/decisions/:id/chain; add res.ok guards
  before .json() on both fetches; encodeURIComponent on decision_id to
  prevent path-injection edge cases

- index.css: add missing @keyframes skeleton-pulse rule (0%/100% opacity
  0.45, 50% opacity 0.85) — KGOverviewTab skeletonBarStyle referenced
  this animation but it was never defined, leaving skeleton bars static

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:16:59 +05:30
KaifAhmad1andClaude Sonnet 4.6 3ea1283626 feat(explorer): add Semantica Knowledge Explorer UI with full feature set
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility

## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
  Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
  Decision Intelligence · Knowledge Audit · Graph Governance

## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store

### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
  INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)

## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button

### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab

## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches

## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
  clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections

## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response

## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone:    rgba(…, 0.04)  → rgba(…, 0.38)
- edgeStructure:   rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection:  rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges:     0.009–0.02    → 0.12–0.26
- Focus edges:     0.16          → 0.42

### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize:    1.8  → 2.4
- inactive/muted:  hide:true → hide:false (dimmed not hidden)

### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize:        0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale:     0.66 → 0.88
- nodeTintMix (colour):   0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72

### Label budget
- overview:   10  → 28 labels
- structure:  36  → 60 labels
- inspection: 80  → 120 labels

### Sigma settings
- renderEdgeLabels:        false → true  (relationship type on every edge)
- edgeLabelSize:           —    → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity:            0.86 → 1.1
- hideLabelsOnMove:        true → false (labels stay visible while panning)
- hideEdgesOnMove:         true → false (edges stay visible while panning)
- minCameraRatio:          —    → 0.04 (prevents zooming inside a node)
- maxCameraRatio:          —    → 8    (graph stays visible when zoomed out)

### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:40:40 +05:30
Mohd Kaif b313604bde Merge pull request #452 from Hawksight-AI/security-enhancement
Security Enhancement — Fix 12 Vulnerabilities (CRITICAL → LOW)
2026-04-12 16:08:51 +05:30
121 changed files with 8261 additions and 1066 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
python-version: '3.11'
- run: pip install build
- run: python -m build
- uses: softprops/action-gh-release@v1
- uses: softprops/action-gh-release@v3
with:
files: dist/*
- uses: pypa/gh-action-pypi-publish@release/v1
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
- name: Comment PR with Security Results
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
**Critical**
+344 -16
View File
@@ -14,6 +14,7 @@
[![CI](https://github.com/Hawksight-AI/semantica/workflows/CI/badge.svg)](https://github.com/Hawksight-AI/semantica/actions)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/sV34vps5hH)
[![X](https://img.shields.io/badge/X-Follow%20Semantica-black?logo=x&logoColor=white)](https://x.com/BuildSemantica)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-Plugin-FF3B30?logo=github&logoColor=white)](https://openclaw.ai)
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
@@ -53,17 +54,245 @@ 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, Claude Desktop, and OpenClaw, 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>
See the community setup guide:
<!-- ── Native Plugin Bundle ──────────────────────────────────────────── -->
<tr>
<th colspan="3" align="left">🔌 Native Plugin Bundle</th>
<th colspan="5" align="left">⚡ MCP Server + Plugin</th>
</tr>
<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>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>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>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><a href="plugins/.windsurf-plugin/">plugin</a></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><a href="plugins/.cline-plugin/">plugin</a></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><a href="plugins/.continue-plugin/">plugin</a></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><a href="plugins/.vscode-plugin/">plugin</a></sub>
</td>
<td align="center" width="12.5%">
<a href="integrations/openclaw/"><img src="https://github.com/openclaw.png?size=120" alt="OpenClaw" width="48" height="48" /></a><br/>
<strong>OpenClaw</strong><br/>
<sub>MCP + <a href="integrations/openclaw/">plugin</a></sub>
</td>
</tr>
- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
<!-- ── MCP Server only · REST API ───────────────────────────────────── -->
<tr>
<th colspan="1" align="left">☁️ MCP Server</th>
<th colspan="7" align="left">🌐 REST API</th>
</tr>
<tr>
<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/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/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/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>
</tr>
<!-- ── Any tool via REST ─────────────────────────────────────────────── -->
<tr>
<th colspan="8" align="left">🔧 Any Tool</th>
</tr>
<tr>
<td align="center" colspan="8">
<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>109 REST endpoints · FastAPI · port 8000</sub>
</td>
</tr>
</table>
### Plugin Bundles (Claude Code · Cursor · Codex)
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 |
| OpenClaw | [`plugins/.openclaw-plugin/`](plugins/.openclaw-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 +576,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 +873,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 +942,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 |
---
@@ -0,0 +1,809 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/Hawksight-AI/semantica/blob/main/cookbook/advanced/14_Datalog_Style_Reasoning.ipynb)\n",
"\n",
"# Datalog-Style Reasoning\n",
"\n",
"End-to-end guide to Semantica's **`DatalogReasoner`** — a native bottom-up semi-naive fixpoint engine — wired together with `GraphBuilder`, `ContextGraph`, `GraphAnalyzer`, `ExplanationGenerator`, and the supporting data-classes (`DatalogFact`, `DatalogRule`, `InferenceResult`, `Rule`).\n",
"\n",
"## What you will build\n",
"\n",
"| Part | Topic | Key classes |\n",
"|------|-------|-------------|\n",
"| 1 | Core API & EDB/IDB concepts | `DatalogReasoner`, `DatalogFact`, `DatalogRule` |\n",
"| 2 | KG → Datalog pipeline | `GraphBuilder`, `GraphAnalyzer`, `DatalogReasoner` |\n",
"| 3 | ContextGraph integration | `ContextGraph`, `DatalogReasoner.load_from_graph()` |\n",
"| 4 | RBAC access-control policy | `GraphBuilder`, `DatalogReasoner`, `ExplanationGenerator` |\n",
"| 5 | Org hierarchy | `ContextGraph`, `DatalogReasoner`, `InferenceResult` |\n",
"| 6 | Engine introspection | `DatalogFact`, `DatalogRule` internal state |\n",
"\n",
"**Related notebooks**\n",
"- [08_Reasoning_and_Inference.ipynb](08_Reasoning_and_Inference.ipynb) — high-level `Reasoner` with IF/THEN syntax\n",
"- [10_Temporal_Knowledge_Graphs.ipynb](10_Temporal_Knowledge_Graphs.ipynb) — temporal reasoning\n",
"\n",
"**Documentation**: [Reasoning API](https://semantica.readthedocs.io/reference/reasoning/) | [KG API](https://semantica.readthedocs.io/reference/kg/) | [Context API](https://semantica.readthedocs.io/reference/context/)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!pip install -qU semantica"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Reasoning ──────────────────────────────────────────────────────────────\n",
"from semantica.reasoning import (\n",
" DatalogReasoner, # native Datalog fixpoint engine\n",
" DatalogFact, # frozen dataclass: predicate + args tuple\n",
" DatalogRule, # dataclass: head + body (list[BodyAtom])\n",
" ExplanationGenerator, # generates NL justifications\n",
" InferenceResult, # result dataclass consumed by ExplanationGenerator\n",
" Rule, # rule dataclass used by ExplanationGenerator\n",
" RuleType, # enum: IMPLICATION | EQUIVALENCE | CONSTRAINT | TRANSFORMATION\n",
")\n",
"\n",
"# ── Knowledge Graph ────────────────────────────────────────────────────────\n",
"from semantica.kg import (\n",
" GraphBuilder, # constructs KG dicts from entity+relationship sources\n",
" GraphAnalyzer, # centrality, communities, connectivity, metrics\n",
")\n",
"\n",
"# ── Context ────────────────────────────────────────────────────────────────\n",
"from semantica.context import ContextGraph # in-memory graph: add_node/add_edge/find_*\n",
"\n",
"print(\"All Semantica classes imported successfully.\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 1 — Core API: EDB Facts, IDB Rules, Fixpoint\n",
"\n",
"### Datalog in 30 seconds\n",
"\n",
"| Term | Meaning | Example |\n",
"|------|---------|--------|\n",
"| EDB (Extensional DB) | Ground facts you assert | `parent(tom, bob)` |\n",
"| IDB (Intensional DB) | Facts derived by rules | `ancestor(tom, ann)` |\n",
"| Rule (Horn clause) | If body → derive head | `ancestor(X,Y) :- parent(X,Y).` |\n",
"| Variable | Uppercase, unified during eval | `X`, `Y`, `Role` |\n",
"| Constant | Lowercase, matches literally | `tom`, `admin` |\n",
"| Fixpoint | Iterate until no new facts appear | `DatalogReasoner.derive_all()` |\n",
"\n",
"### The canonical example — transitive ancestry"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 1: create engine ──────────────────────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"\n",
"# ── Step 2: load EDB (ground facts) ───────────────────────────────────────\n",
"# Syntax: predicate(constant1, constant2) — constants must be lowercase\n",
"edb_facts = [\n",
" \"parent(tom, bob)\",\n",
" \"parent(bob, ann)\",\n",
" \"parent(ann, pat)\",\n",
"]\n",
"for f in edb_facts:\n",
" dr.add_fact(f)\n",
"\n",
"print(f\"EDB loaded: {len(dr._all_facts)} ground facts\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 3: add IDB rules (Horn clauses) ──────────────────────────────────\n",
"# Syntax: head(Vars) :- body_atom1(Vars), body_atom2(Vars).\n",
"# Variables start with uppercase; trailing '.' is optional\n",
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Y).\")\n",
"dr.add_rule(\"ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).\") # recursive\n",
"\n",
"print(f\"Rules loaded: {len(dr._rules)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 4: fixpoint evaluation ────────────────────────────────────────────\n",
"# derive_all() runs semi-naive bottom-up evaluation until no new facts appear\n",
"all_facts: list[str] = dr.derive_all()\n",
"\n",
"ancestor_strs = sorted(f for f in all_facts if f.startswith(\"ancestor\"))\n",
"print(f\"Derived {len(ancestor_strs)} ancestor facts:\")\n",
"for f in ancestor_strs:\n",
" print(\" \", f)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Step 5: query ──────────────────────────────────────────────────────────\n",
"# Use '?varname' placeholders — query() auto-calls derive_all() if needed\n",
"# Returns: list[dict] e.g. [{\"Y\": \"bob\"}, {\"Y\": \"ann\"}, {\"Y\": \"pat\"}]\n",
"\n",
"descendants = dr.query(\"ancestor(tom, ?Y)\")\n",
"print(\"All descendants of tom:\", sorted(r[\"Y\"] for r in descendants))\n",
"\n",
"ancestors_of_pat = dr.query(\"ancestor(?X, pat)\")\n",
"print(\"All ancestors of pat: \", sorted(r[\"X\"] for r in ancestors_of_pat))\n",
"\n",
"all_pairs = dr.query(\"ancestor(?X, ?Y)\")\n",
"print(f\"\\nAll ancestor pairs ({len(all_pairs)}):\")\n",
"for row in sorted(all_pairs, key=lambda r: (r[\"X\"], r[\"Y\"])):\n",
" print(f\" {row['X']:6s} → {row['Y']}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 2 — GraphBuilder → DatalogReasoner Pipeline\n",
"\n",
"`GraphBuilder` constructs a structured `{\"entities\": [...], \"relationships\": [...]}` dict from your data. We then:\n",
"\n",
"1. Analyse the graph with `GraphAnalyzer` to understand structure.\n",
"2. Feed `kg[\"relationships\"]` into `DatalogReasoner` as EDB facts.\n",
"3. Apply recursive Datalog rules over the KG."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build a software-dependency KG ────────────────────────────────────────\n",
"entities = [\n",
" {\"id\": \"pythonsdk\", \"name\": \"Python SDK\", \"type\": \"Component\"},\n",
" {\"id\": \"restapi\", \"name\": \"REST API\", \"type\": \"Component\"},\n",
" {\"id\": \"authservice\", \"name\": \"Auth Service\", \"type\": \"Component\"},\n",
" {\"id\": \"database\", \"name\": \"Database\", \"type\": \"Component\"},\n",
" {\"id\": \"dashboard\", \"name\": \"Dashboard\", \"type\": \"Component\"},\n",
" {\"id\": \"analytics\", \"name\": \"Analytics\", \"type\": \"Component\"},\n",
"]\n",
"relationships = [\n",
" {\"source\": \"pythonsdk\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
" {\"source\": \"restapi\", \"target\": \"authservice\", \"type\": \"depends_on\"},\n",
" {\"source\": \"authservice\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
" {\"source\": \"dashboard\", \"target\": \"restapi\", \"type\": \"depends_on\"},\n",
" {\"source\": \"dashboard\", \"target\": \"analytics\", \"type\": \"depends_on\"},\n",
" {\"source\": \"analytics\", \"target\": \"database\", \"type\": \"depends_on\"},\n",
"]\n",
"\n",
"# GraphBuilder validates, deduplicates, and packages the data\n",
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
"kg = builder.build([{\"entities\": entities, \"relationships\": relationships}])\n",
"\n",
"print(f\"KG built — entities: {len(kg['entities'])}, relationships: {len(kg['relationships'])}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Analyse the graph structure before reasoning ───────────────────────────\n",
"# GraphAnalyzer provides centrality, communities, connectivity, and metrics\n",
"analyzer = GraphAnalyzer()\n",
"metrics = analyzer.compute_metrics(graph=kg)\n",
"\n",
"print(\"Graph structure:\")\n",
"print(f\" Nodes : {metrics['num_nodes']}\")\n",
"print(f\" Edges : {metrics['num_edges']}\")\n",
"if \"density\" in metrics:\n",
" print(f\" Density : {metrics['density']:.3f}\")\n",
"if \"is_connected\" in metrics:\n",
" print(f\" Connected : {metrics['is_connected']}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load KG relationships as EDB facts ────────────────────────────────────\n",
"# GraphBuilder output dicts use the same source/target/type shape that\n",
"# DatalogReasoner.add_fact() natively understands\n",
"dr = DatalogReasoner()\n",
"\n",
"for rel in kg[\"relationships\"]:\n",
" dr.add_fact(rel) # dict path: {\"source\": ..., \"target\": ..., \"type\": ...}\n",
"\n",
"print(f\"EDB loaded: {len(dr._all_facts)} dependency facts\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Transitive dependency closure ─────────────────────────────────────────\n",
"# 'depends_on' is the predicate name that add_fact inferred from 'type'\n",
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Y).\")\n",
"dr.add_rule(\"transitive_dep(X, Y) :- depends_on(X, Z), transitive_dep(Z, Y).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"# Everything that transitively depends on the database\n",
"db_deps = sorted(r[\"X\"] for r in dr.query(\"transitive_dep(?X, database)\"))\n",
"print(\"Components that transitively depend on Database:\")\n",
"for c in db_deps:\n",
" print(\" \", c)\n",
"\n",
"# What does pythonsdk transitively depend on?\n",
"sdk_chain = sorted(r[\"Y\"] for r in dr.query(\"transitive_dep(pythonsdk, ?Y)\"))\n",
"print(f\"\\nPython SDK full dependency chain: {sdk_chain}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 3 — ContextGraph + `load_from_graph()`\n",
"\n",
"`DatalogReasoner.load_from_graph(graph)` accepts any `ContextGraph` directly: it calls `graph.find_edges()` and `graph.find_nodes()` and converts each result into EDB facts automatically."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build an in-memory ContextGraph ───────────────────────────────────────\n",
"# ContextGraph.add_node / add_edge are the canonical way to build in-memory KGs\n",
"cg = ContextGraph()\n",
"\n",
"# Nodes\n",
"for person in [\"alice\", \"bob\", \"carol\", \"dave\", \"eve\"]:\n",
" cg.add_node(person, node_type=\"person\", name=person.capitalize())\n",
"\n",
"# Directed \"follows\" edges\n",
"for src, dst in [(\"alice\", \"bob\"), (\"bob\", \"carol\"), (\"carol\", \"dave\"), (\"alice\", \"eve\"), (\"eve\", \"carol\")]:\n",
" cg.add_edge(src, dst, edge_type=\"follows\")\n",
"\n",
"# Verify the graph built correctly\n",
"nodes = cg.find_nodes(node_type=\"person\")\n",
"edges = cg.find_edges(edge_type=\"follows\")\n",
"print(f\"ContextGraph — nodes: {len(nodes)}, edges: {len(edges)}\")\n",
"print(\"Edges:\", [(e.get(\"source\", e.get(\"source_id\")), e.get(\"target\", e.get(\"target_id\"))) for e in edges])"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── load_from_graph() ingests the ContextGraph directly ───────────────────\n",
"dr = DatalogReasoner()\n",
"n_loaded = dr.load_from_graph(cg) # calls cg.find_edges() + cg.find_nodes() internally\n",
"print(f\"Facts loaded from ContextGraph: {n_loaded}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Influence reach via transitive 'follows' ──────────────────────────────\n",
"dr.add_rule(\"influence(X, Y) :- follows(X, Y).\")\n",
"dr.add_rule(\"influence(X, Y) :- follows(X, Z), influence(Z, Y).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"# Who can alice reach?\n",
"alice_reach = sorted(r[\"Y\"] for r in dr.query(\"influence(alice, ?Y)\"))\n",
"print(f\"Alice's influence reach : {alice_reach}\")\n",
"\n",
"# Who can reach dave?\n",
"reach_dave = sorted(r[\"X\"] for r in dr.query(\"influence(?X, dave)\"))\n",
"print(f\"Who can influence dave : {reach_dave}\")\n",
"\n",
"# Full influence matrix\n",
"all_influence = dr.query(\"influence(?X, ?Y)\")\n",
"print(f\"\\nTotal influence pairs: {len(all_influence)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 4 — RBAC Access-Control Policy\n",
"\n",
"We model a role-based access-control (RBAC) system:\n",
"\n",
"1. Use `GraphBuilder` to build a structured KG of users, roles, and permissions.\n",
"2. Load it into `DatalogReasoner` for policy inference.\n",
"3. Use `ExplanationGenerator` to produce audit-ready NL justifications."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build RBAC graph with GraphBuilder ────────────────────────────────────\n",
"rbac_entities = [\n",
" # Users\n",
" {\"id\": \"alice\", \"type\": \"User\", \"name\": \"Alice\"},\n",
" {\"id\": \"bob\", \"type\": \"User\", \"name\": \"Bob\"},\n",
" {\"id\": \"carol\", \"type\": \"User\", \"name\": \"Carol\"},\n",
" {\"id\": \"dave\", \"type\": \"User\", \"name\": \"Dave\"},\n",
" # Roles\n",
" {\"id\": \"admin\", \"type\": \"Role\", \"name\": \"Administrator\"},\n",
" {\"id\": \"editor\", \"type\": \"Role\", \"name\": \"Editor\"},\n",
" {\"id\": \"viewer\", \"type\": \"Role\", \"name\": \"Viewer\"},\n",
" # Permissions\n",
" {\"id\": \"read\", \"type\": \"Permission\"},\n",
" {\"id\": \"write\", \"type\": \"Permission\"},\n",
" {\"id\": \"delete\", \"type\": \"Permission\"},\n",
" {\"id\": \"manage_users\", \"type\": \"Permission\"},\n",
"]\n",
"rbac_relationships = [\n",
" # User → Role assignments\n",
" {\"source\": \"alice\", \"target\": \"admin\", \"type\": \"has_role\"},\n",
" {\"source\": \"bob\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
" {\"source\": \"carol\", \"target\": \"viewer\", \"type\": \"has_role\"},\n",
" {\"source\": \"dave\", \"target\": \"editor\", \"type\": \"has_role\"},\n",
" # Role hierarchy (admin inherits from editor, editor from viewer)\n",
" {\"source\": \"admin\", \"target\": \"editor\", \"type\": \"role_inherits\"},\n",
" {\"source\": \"editor\", \"target\": \"viewer\", \"type\": \"role_inherits\"},\n",
" # Role → Permission grants\n",
" {\"source\": \"viewer\", \"target\": \"read\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"editor\", \"target\": \"write\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"admin\", \"target\": \"delete\", \"type\": \"role_has_perm\"},\n",
" {\"source\": \"admin\", \"target\": \"manage_users\", \"type\": \"role_has_perm\"},\n",
"]\n",
"\n",
"builder = GraphBuilder(merge_entities=True, resolve_conflicts=False)\n",
"rbac_kg = builder.build([{\"entities\": rbac_entities, \"relationships\": rbac_relationships}])\n",
"\n",
"print(f\"RBAC KG — entities: {len(rbac_kg['entities'])}, relationships: {len(rbac_kg['relationships'])}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Analyse RBAC graph structure ──────────────────────────────────────────\n",
"analyzer = GraphAnalyzer()\n",
"metrics = analyzer.compute_metrics(graph=rbac_kg)\n",
"centrality = analyzer.calculate_centrality(rbac_kg, centrality_type=\"degree\")\n",
"\n",
"print(f\"RBAC graph — {metrics['num_nodes']} nodes, {metrics['num_edges']} edges\")\n",
"if isinstance(centrality, dict) and \"degree\" in centrality:\n",
" top = sorted(centrality[\"degree\"].items(), key=lambda x: x[1], reverse=True)[:3]\n",
" print(\"Top-3 nodes by degree centrality:\", top)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load RBAC KG into DatalogReasoner ────────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"\n",
"for rel in rbac_kg[\"relationships\"]:\n",
" dr.add_fact(rel) # {source, target, type} → predicate(source, target)\n",
"\n",
"# ── IDB rules: transitive role hierarchy ─────────────────────────────────\n",
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, R2).\")\n",
"dr.add_rule(\"effective_role(R, R2) :- role_inherits(R, Z), effective_role(Z, R2).\")\n",
"\n",
"# ── IDB rules: inherited permissions ─────────────────────────────────────\n",
"dr.add_rule(\"role_can(R, P) :- role_has_perm(R, P).\")\n",
"dr.add_rule(\"role_can(R, P) :- effective_role(R, R2), role_has_perm(R2, P).\")\n",
"\n",
"# ── IDB rules: user effective permissions ────────────────────────────────\n",
"dr.add_rule(\"can(U, P) :- has_role(U, R), role_can(R, P).\")\n",
"\n",
"dr.derive_all()\n",
"\n",
"print(\"User permissions derived via role-hierarchy inference:\")\n",
"for user in [\"alice\", \"bob\", \"carol\", \"dave\"]:\n",
" perms = sorted(r[\"P\"] for r in dr.query(f\"can({user}, ?P)\"))\n",
" print(f\" {user:6s}: {perms}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── ExplanationGenerator — audit-ready NL justification ──────────────────\n",
"# ExplanationGenerator works with InferenceResult objects.\n",
"# We construct one manually to represent a derived Datalog conclusion.\n",
"\n",
"explainer = ExplanationGenerator(detail_level=\"detailed\")\n",
"\n",
"# Build the Rule object that represents the permission derivation chain\n",
"perm_rule = Rule(\n",
" rule_id=\"rbac_perm_chain\",\n",
" name=\"RBAC permission via role hierarchy\",\n",
" conditions=[\"has_role(alice, admin)\", \"effective_role(admin, viewer)\", \"role_has_perm(viewer, read)\"],\n",
" conclusion=\"can(alice, read)\",\n",
" rule_type=RuleType.IMPLICATION,\n",
" confidence=1.0,\n",
")\n",
"\n",
"# Build InferenceResult representing the Datalog conclusion\n",
"result = InferenceResult(\n",
" conclusion=\"can(alice, read)\",\n",
" rule_used=perm_rule,\n",
" premises=[\n",
" \"has_role(alice, admin)\",\n",
" \"role_inherits(admin, editor)\",\n",
" \"role_inherits(editor, viewer)\",\n",
" \"role_has_perm(viewer, read)\",\n",
" ],\n",
" confidence=1.0,\n",
")\n",
"\n",
"# Generate NL explanation\n",
"explanation = explainer.generate_explanation(result)\n",
"print(\"Explanation type :\", explanation.explanation_type)\n",
"print(\"Conclusion :\", explanation.conclusion)\n",
"print(\"Natural language :\", explanation.natural_language)\n",
"print(\"Reasoning steps :\", len(explanation.reasoning_path.steps))"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inverse queries ───────────────────────────────────────────────────────\n",
"deleters = sorted(r[\"U\"] for r in dr.query(\"can(?U, delete)\"))\n",
"print(\"Who can delete:\", deleters)\n",
"\n",
"writers = sorted(r[\"U\"] for r in dr.query(\"can(?U, write)\"))\n",
"print(\"Who can write: \", writers)\n",
"\n",
"# All (user, permission) pairs — full policy matrix\n",
"all_caps = dr.query(\"can(?U, ?P)\")\n",
"print(f\"\\nTotal (user, permission) pairs: {len(all_caps)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 5 — Organisation Hierarchy with ContextGraph\n",
"\n",
"We model a company org-chart using `ContextGraph` and derive:\n",
"- `manages(M, E)` — direct and transitive management\n",
"- `skip_level(M, E)` — two hops up the chain\n",
"- `same_team(X, Y)` — shared team membership"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── ContextGraph: org chart ────────────────────────────────────────────────\n",
"org = ContextGraph()\n",
"\n",
"# Add employees as nodes with metadata\n",
"staff = [\n",
" (\"eng1\", \"engineer\", \"backend\"),\n",
" (\"eng2\", \"engineer\", \"backend\"),\n",
" (\"eng3\", \"engineer\", \"frontend\"),\n",
" (\"techlead\", \"lead\", \"engineering\"),\n",
" (\"design1\", \"designer\", \"ux\"),\n",
" (\"design2\", \"designer\", \"ux\"),\n",
" (\"designlead\",\"lead\", \"design\"),\n",
" (\"vpeng\", \"vp\", \"engineering\"),\n",
" (\"cto\", \"executive\", \"leadership\"),\n",
"]\n",
"for emp_id, role, team in staff:\n",
" org.add_node(emp_id, node_type=\"employee\", role=role, team=team)\n",
"\n",
"# Reporting lines\n",
"reports_to = [\n",
" (\"eng1\", \"techlead\"), (\"eng2\", \"techlead\"), (\"eng3\", \"techlead\"),\n",
" (\"techlead\", \"vpeng\"),\n",
" (\"design1\", \"designlead\"), (\"design2\", \"designlead\"),\n",
" (\"designlead\", \"vpeng\"),\n",
" (\"vpeng\", \"cto\"),\n",
"]\n",
"for employee, manager in reports_to:\n",
" org.add_edge(employee, manager, edge_type=\"reports_to\")\n",
"\n",
"# Team membership edges\n",
"teams = [\n",
" (\"eng1\", \"backend\"), (\"eng2\", \"backend\"), (\"eng3\", \"frontend\"),\n",
" (\"design1\", \"ux\"), (\"design2\", \"ux\"),\n",
"]\n",
"for emp, team in teams:\n",
" org.add_edge(emp, team, edge_type=\"in_team\")\n",
" if not org.find_nodes(node_type=\"team\"):\n",
" org.add_node(team, node_type=\"team\")\n",
"\n",
"print(f\"ContextGraph — nodes: {len(org.find_nodes())}, edges: {len(org.find_edges())}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Load org chart into DatalogReasoner ───────────────────────────────────\n",
"dr = DatalogReasoner()\n",
"n = dr.load_from_graph(org) # uses org.find_edges() + org.find_nodes()\n",
"print(f\"Facts loaded via load_from_graph(): {n}\")\n",
"\n",
"# ── IDB rules ─────────────────────────────────────────────────────────────\n",
"# Transitive management chain\n",
"dr.add_rule(\"manages(M, E) :- reports_to(E, M).\")\n",
"dr.add_rule(\"manages(M, E) :- reports_to(E, Z), manages(M, Z).\")\n",
"\n",
"# Skip-level: exactly two reporting hops\n",
"dr.add_rule(\"skip_level(M, E) :- reports_to(E, Z), reports_to(Z, M).\")\n",
"\n",
"# Same team\n",
"dr.add_rule(\"same_team(X, Y) :- in_team(X, T), in_team(Y, T).\")\n",
"\n",
"dr.derive_all()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Query org hierarchy ────────────────────────────────────────────────────\n",
"# Everyone under CTO\n",
"under_cto = sorted(r[\"E\"] for r in dr.query(\"manages(cto, ?E)\"))\n",
"print(f\"CTO manages ({len(under_cto)} people): {under_cto}\")\n",
"\n",
"# VP Eng's direct + indirect reports\n",
"under_vp = sorted(r[\"E\"] for r in dr.query(\"manages(vpeng, ?E)\"))\n",
"print(f\"VP Eng manages : {under_vp}\")\n",
"\n",
"# Skip-level reports to CTO (people two hops below CTO)\n",
"skip = sorted(r[\"E\"] for r in dr.query(\"skip_level(cto, ?E)\"))\n",
"print(f\"CTO skip-level reports : {skip}\")\n",
"\n",
"# eng1's teammates\n",
"mates = [r[\"Y\"] for r in dr.query(\"same_team(eng1, ?Y)\") if r[\"Y\"] != \"eng1\"]\n",
"print(f\"eng1's teammates : {sorted(mates)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Build an InferenceResult and explain an org query ────────────────────\n",
"explainer = ExplanationGenerator(detail_level=\"verbose\")\n",
"\n",
"mgmt_rule = Rule(\n",
" rule_id=\"transitive_manages\",\n",
" name=\"Transitive management chain\",\n",
" conditions=[\"reports_to(eng1, techlead)\", \"manages(vpeng, techlead)\"],\n",
" conclusion=\"manages(vpeng, eng1)\",\n",
" rule_type=RuleType.IMPLICATION,\n",
" confidence=1.0,\n",
")\n",
"result = InferenceResult(\n",
" conclusion=\"manages(vpeng, eng1)\",\n",
" rule_used=mgmt_rule,\n",
" premises=[\"reports_to(eng1, techlead)\", \"reports_to(techlead, vpeng)\"],\n",
" confidence=1.0,\n",
")\n",
"\n",
"exp = explainer.generate_explanation(result)\n",
"print(exp.natural_language)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## Part 6 — Engine Introspection: DatalogFact & DatalogRule\n",
"\n",
"After reasoning, the engine's internal state is fully accessible via `DatalogFact` and `DatalogRule` data-classes. Use this for auditing, debugging, or downstream export."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inspect DatalogRule objects ────────────────────────────────────────────\n",
"# dr._rules → List[DatalogRule]\n",
"# DatalogRule.head_predicate, .head_args, .body (body = List[BodyAtom])\n",
"print(\"Rules in engine:\")\n",
"for rule in dr._rules:\n",
" body_str = \", \".join(\n",
" f\"{atom.predicate}({', '.join(atom.args)})\"\n",
" for atom in rule.body\n",
" )\n",
" head_str = f\"{rule.head_predicate}({', '.join(rule.head_args)})\"\n",
" print(f\" {head_str} :- {body_str}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Inspect DatalogFact objects ────────────────────────────────────────────\n",
"# dr._all_facts → Set[DatalogFact] (EDB + IDB combined after derive_all)\n",
"# dr._fact_index → Dict[predicate, Set[DatalogFact]]\n",
"\n",
"from collections import Counter\n",
"\n",
"# Count facts per predicate\n",
"predicate_counts = Counter(f.predicate for f in dr._all_facts)\n",
"print(\"Facts per predicate (EDB + derived IDB):\")\n",
"for pred, count in sorted(predicate_counts.items()):\n",
" print(f\" {pred:20s}: {count}\")\n",
"print(f\"\\n TOTAL: {len(dr._all_facts)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Separate EDB from IDB ─────────────────────────────────────────────────\n",
"# EDB predicates are the ones we added via add_fact (not derived by rules)\n",
"idb_predicates = {rule.head_predicate for rule in dr._rules}\n",
"edb_predicates = {f.predicate for f in dr._all_facts} - idb_predicates\n",
"\n",
"print(f\"EDB predicates (base facts) : {sorted(edb_predicates)}\")\n",
"print(f\"IDB predicates (derived) : {sorted(idb_predicates)}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── Sample DatalogFact structure ──────────────────────────────────────────\n",
"# DatalogFact is a frozen dataclass: predicate: str, args: Tuple[str, ...]\n",
"manages_facts = sorted(dr._fact_index.get(\"manages\", []), key=lambda f: f.args)\n",
"print(f\"First 5 'manages' DatalogFact objects ({len(manages_facts)} total):\")\n",
"for fact in manages_facts[:5]:\n",
" # Access predicate and args directly from the dataclass\n",
" print(f\" DatalogFact(predicate={fact.predicate!r}, args={fact.args})\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ── clear() resets the engine completely ─────────────────────────────────\n",
"print(f\"Facts before clear(): {len(dr._all_facts)}\")\n",
"dr.clear()\n",
"print(f\"Facts after clear(): {len(dr._all_facts)}\")\n",
"print(f\"Rules after clear(): {len(dr._rules)}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## API Summary\n",
"\n",
"### DatalogReasoner\n",
"\n",
"| Method | Input | Output | Notes |\n",
"|--------|-------|--------|-------|\n",
"| `add_fact(f)` | `str` or `dict` | `None` | string: `\"pred(a, b)\"` · dict: `{source, target, type}` |\n",
"| `add_rule(s)` | `str` | `None` | Horn clause: `\"head(X) :- body(X, Y).\"` |\n",
"| `derive_all()` | — | `list[str]` | semi-naive fixpoint; idempotent |\n",
"| `query(pat)` | `str` | `list[dict]` | `\"pred(a, ?Y)\"` → `[{\"Y\": ...}]` |\n",
"| `load_from_graph(g)` | `ContextGraph` | `int` | facts loaded count |\n",
"| `clear()` | — | `None` | resets engine |\n",
"\n",
"### Syntax rules\n",
"\n",
"| Item | Rule | Example |\n",
"|------|------|---------|\n",
"| Variable | Starts **uppercase** | `X`, `Role`, `Parent` |\n",
"| Constant | All **lowercase** | `tom`, `admin`, `database` |\n",
"| Query var | Prefix `?` | `?X`, `?Y`, `?Role` |\n",
"| Rule body | `:-` separator, comma between atoms | `head(X) :- a(X, Z), b(Z, Y).` |\n",
"\n",
"### Class map\n",
"\n",
"```\n",
"GraphBuilder.build() → kg dict {entities, relationships}\n",
" ↓ kg[\"relationships\"] → dr.add_fact(rel)\n",
" \n",
"ContextGraph.add_node/add_edge → in-memory graph\n",
" ↓ dr.load_from_graph(cg)\n",
" \n",
"DatalogReasoner.add_rule() → Horn clause rules\n",
"DatalogReasoner.derive_all() → semi-naive fixpoint\n",
"DatalogReasoner.query() → result rows\n",
" ↓ build InferenceResult\n",
" \n",
"ExplanationGenerator → natural language justification\n",
"GraphAnalyzer → graph structure metrics pre/post reasoning\n",
"DatalogFact / DatalogRule → introspect engine state\n",
"```"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.10.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+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: '/',
+137
View File
@@ -0,0 +1,137 @@
# Semantica × OpenClaw Integration
Connect [OpenClaw](https://openclaw.ai) — the open-source personal AI agent — to Semantica's full knowledge-graph and decision-intelligence stack.
Two integration paths are available:
| Path | When to use |
|---|---|
| **MCP (recommended)** | OpenClaw Gateway is running; zero extra code needed |
| **REST / native tool** | Embedding Semantica directly in a SOUL.md agent config |
---
## Path 1 — MCP Server (recommended)
### 1. Start the Semantica MCP server
```bash
python -m semantica.mcp_server
```
### 2. Add to `mcporter.json`
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"],
"transport": "stdio"
}
}
}
```
### 3. Restart the OpenClaw Gateway
```bash
openclaw gateway restart
```
All **12 Semantica tools** are now available to any OpenClaw agent:
| Tool | What it does |
|---|---|
| `extract_entities` | Named entity recognition from text |
| `extract_relations` | Relation / triplet extraction from text |
| `record_decision` | Record a decision with causal links |
| `query_decisions` | Search recorded decisions |
| `find_precedents` | Find past decisions similar to a query |
| `get_causal_chain` | Trace cause-effect chains from a node |
| `add_entity` | Add a node to the knowledge graph |
| `add_relationship` | Add an edge between two nodes |
| `run_reasoning` | Forward-chain rules over facts |
| `get_graph_analytics` | Centrality, communities, topology stats |
| `export_graph` | Export graph (JSON, RDF, GraphML, …) |
| `get_graph_summary` | High-level graph overview |
**3 resources** are also exposed: `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`.
---
## Path 2 — Native Tool (REST)
Use `OpenClawKGTool` when you prefer a direct Python integration without the MCP gateway.
### Install
```bash
pip install semantica[openclaw] # pulls in 'requests'
```
### Quick start
```python
from integrations.openclaw import OpenClawKGTool
tool = OpenClawKGTool(base_url="http://localhost:8000")
# Extract knowledge from text
entities = tool.extract_entities("OpenClaw is an open-source AI agent built in Python.")
relations = tool.extract_relations("Alice manages the OpenClaw project at Hawksight.")
# Record and query decisions
tool.record_decision("Deploy model v2 to production", context="latency improved by 40%")
precedents = tool.find_precedents("roll back production deployment")
# Graph analytics
summary = tool.get_graph_summary()
analytics = tool.get_graph_analytics()
# Export
ttl = tool.export_graph(fmt="ttl")
```
### Generate `mcporter.json` programmatically
```python
from integrations.openclaw import OpenClawMCPConfig
cfg = OpenClawMCPConfig()
print(cfg.to_json()) # → paste into mcporter.json
```
---
## SOUL.md agent snippet
Add Semantica to any OpenClaw agent by referencing the tool in your `SOUL.md`:
```markdown
## Tools
- name: semantica_kg
description: >
Semantica knowledge-graph tool. Supports entity extraction, decision
recording, graph querying, causal chain analysis, reasoning, and
multi-format export.
endpoint: http://localhost:8000
auth: none
## Instructions
You have access to `semantica_kg`. Use it to:
- Extract entities and relations from any text the user provides.
- Record important decisions and retrieve precedents before recommending actions.
- Run graph analytics and export results when the user asks for a summary.
```
---
## Requirements
- Python 3.8+
- `pip install semantica` (core)
- `pip install semantica[openclaw]` (adds `requests` for the REST path)
- OpenClaw ≥ latest — [openclaw.ai](https://openclaw.ai)
+57
View File
@@ -0,0 +1,57 @@
"""
Semantica × OpenClaw Integration
==================================
First-class integration between the Semantica semantic intelligence stack and
`OpenClaw <https://openclaw.ai>`_ the open-source personal AI agent platform.
OpenClaw connects to external tools via MCP (Model Context Protocol). This
integration exposes the full Semantica MCP surface (12 tools, 3 resources) to
any OpenClaw agent and also ships a lightweight ``OpenClawKGTool`` that can be
dropped directly into an OpenClaw SOUL.md tool-list as a native tool.
Public surface
--------------
OpenClawKGTool Thin wrapper around the Semantica REST API usable as an
OpenClaw native tool (no MCP gateway required)
OpenClawMCPConfig Helper that emits the ``mcporter.json`` snippet needed to
wire Semantica's MCP server into an OpenClaw gateway
Quick start
-----------
pip install semantica
>>> from integrations.openclaw import OpenClawKGTool, OpenClawMCPConfig
>>> print(OpenClawMCPConfig().to_json()) # paste into mcporter.json
>>> tool = OpenClawKGTool(base_url="http://localhost:8000")
>>> result = tool.extract("OpenClaw is an open-source AI agent framework.")
MCP quick start
---------------
Run the Semantica MCP server once::
python -m semantica.mcp_server
Then add the printed config snippet to your OpenClaw ``mcporter.json`` and
restart the OpenClaw Gateway::
openclaw gateway restart
All 12 Semantica tools are then available as native OpenClaw agent tools.
Compatibility
-------------
Requires ``semantica >= 0.3.0``. The MCP path requires ``python >= 3.8`` and
a running ``semantica.mcp_server`` instance. The REST path requires a running
``semantica.server`` instance (``python -m semantica.server``, port 8000 by
default).
"""
from .mcp_tool import OpenClawKGTool, OpenClawMCPConfig
__all__ = [
"OpenClawKGTool",
"OpenClawMCPConfig",
]
__version__ = "0.1.0"
+253
View File
@@ -0,0 +1,253 @@
"""
OpenClaw Semantica bridge
============================
Two integration paths:
1. **MCP (recommended)** ``OpenClawMCPConfig`` emits the ``mcporter.json``
snippet that wires Semantica's MCP server into the OpenClaw Gateway.
All 12 Semantica MCP tools become native OpenClaw agent tools with no
extra code.
2. **REST** ``OpenClawKGTool`` is a plain Python class that calls the
Semantica REST API (port 8000) and can be registered as an OpenClaw
native tool via SOUL.md ``tools:`` entries.
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# MCP config helper
# ---------------------------------------------------------------------------
class OpenClawMCPConfig:
"""
Generates the ``mcporter.json`` entry needed to connect Semantica's MCP
server to the OpenClaw Gateway.
Parameters
----------
server_command:
Shell command used to launch the Semantica MCP server.
Defaults to ``"python -m semantica.mcp_server"``.
transport:
MCP transport protocol. OpenClaw supports ``"stdio"`` (default)
and ``"sse"``.
name:
Key used in ``mcporter.json``. Defaults to ``"semantica"``.
Example
-------
>>> cfg = OpenClawMCPConfig()
>>> print(cfg.to_json())
# → paste into ~/.openclaw/mcporter.json, then:
# → openclaw gateway restart
"""
def __init__(
self,
server_command: str = "python -m semantica.mcp_server",
transport: str = "stdio",
name: str = "semantica",
) -> None:
self.server_command = server_command
self.transport = transport
self.name = name
def to_dict(self) -> Dict[str, Any]:
"""Return the config as a plain dict."""
parts = self.server_command.split()
return {
"mcpServers": {
self.name: {
"command": parts[0],
"args": parts[1:],
"transport": self.transport,
}
}
}
def to_json(self, indent: int = 2) -> str:
"""Return the config as a JSON string."""
return json.dumps(self.to_dict(), indent=indent)
def __repr__(self) -> str: # pragma: no cover
return f"OpenClawMCPConfig(name={self.name!r}, transport={self.transport!r})"
# ---------------------------------------------------------------------------
# REST-based native tool
# ---------------------------------------------------------------------------
class OpenClawKGTool:
"""
A Semantica knowledge-graph tool callable from an OpenClaw agent.
Wraps the Semantica REST API so that an OpenClaw agent configured with
this tool (via SOUL.md ``tools:`` entries or programmatic registration)
can extract entities, record decisions, query the graph, and more
without requiring the MCP gateway.
Parameters
----------
base_url:
Base URL of the running Semantica REST server.
Defaults to ``"http://localhost:8000"``.
timeout:
Request timeout in seconds. Defaults to ``30``.
Notes
-----
``requests`` is used for HTTP calls. It is listed as an optional
dependency under ``semantica[openclaw]``; install it with::
pip install semantica[openclaw]
"""
TOOL_NAME = "semantica_kg"
TOOL_DESCRIPTION = (
"Semantica knowledge-graph tool. "
"Supports entity extraction, decision recording, graph querying, "
"causal chain analysis, reasoning, and multi-format export."
)
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._session: Any = None
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_session(self) -> Any:
if self._session is None:
try:
import requests
self._session = requests.Session()
except ImportError as exc:
raise ImportError(
"The 'requests' package is required for OpenClawKGTool. "
"Install it with: pip install semantica[openclaw]"
) from exc
return self._session
def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
session = self._get_session()
url = f"{self.base_url}{endpoint}"
response = session.post(url, json=payload, timeout=self.timeout)
response.raise_for_status()
return response.json()
def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
session = self._get_session()
url = f"{self.base_url}{endpoint}"
response = session.get(url, params=params or {}, timeout=self.timeout)
response.raise_for_status()
return response.json()
# ------------------------------------------------------------------
# Extraction
# ------------------------------------------------------------------
def extract(self, text: str) -> Dict[str, Any]:
"""Extract entities and relations from *text*."""
return self._post("/extract", {"text": text})
def extract_entities(self, text: str) -> List[Dict[str, Any]]:
"""Return only the entity list from *text*."""
result = self.extract(text)
return result.get("entities", [])
def extract_relations(self, text: str) -> List[Dict[str, Any]]:
"""Return only the relation list from *text*."""
result = self.extract(text)
return result.get("relations", [])
# ------------------------------------------------------------------
# Graph mutation
# ------------------------------------------------------------------
def add_entity(self, label: str, entity_type: str = "Entity", **properties: Any) -> Dict[str, Any]:
"""Add a node to the knowledge graph."""
return self._post("/entities", {"label": label, "type": entity_type, **properties})
def add_relationship(
self,
source: str,
target: str,
relation_type: str,
**properties: Any,
) -> Dict[str, Any]:
"""Add an edge between *source* and *target*."""
return self._post(
"/relationships",
{"source": source, "target": target, "type": relation_type, **properties},
)
# ------------------------------------------------------------------
# Decisions
# ------------------------------------------------------------------
def record_decision(
self,
decision_text: str,
context: Optional[str] = None,
**metadata: Any,
) -> Dict[str, Any]:
"""Record a decision in the graph."""
payload: Dict[str, Any] = {"decision": decision_text}
if context:
payload["context"] = context
payload.update(metadata)
return self._post("/decisions", payload)
def query_decisions(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Search recorded decisions."""
result = self._get("/decisions/search", {"q": query, "limit": limit})
return result.get("decisions", [])
def find_precedents(self, decision_text: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Find past decisions similar to *decision_text*."""
result = self._post("/decisions/precedents", {"decision": decision_text, "top_k": top_k})
return result.get("precedents", [])
# ------------------------------------------------------------------
# Analytics & reasoning
# ------------------------------------------------------------------
def get_causal_chain(self, node_id: str, depth: int = 3) -> Dict[str, Any]:
"""Retrieve the causal chain rooted at *node_id*."""
return self._get("/causal-chain", {"node_id": node_id, "depth": depth})
def run_reasoning(self, rules: List[str], facts: List[str]) -> Dict[str, Any]:
"""Run the Semantica forward-chaining reasoner."""
return self._post("/reason", {"rules": rules, "facts": facts})
def get_graph_analytics(self) -> Dict[str, Any]:
"""Return graph-level analytics (centrality, communities, etc.)."""
return self._get("/analytics")
# ------------------------------------------------------------------
# Export
# ------------------------------------------------------------------
def export_graph(self, fmt: str = "json") -> str:
"""Export the graph in *fmt* (``json``, ``ttl``, ``graphml``, …)."""
result = self._get("/export", {"format": fmt})
return result.get("data", "")
# ------------------------------------------------------------------
# Summary
# ------------------------------------------------------------------
def get_graph_summary(self) -> Dict[str, Any]:
"""Return a high-level summary of the current graph."""
return self._get("/graph/summary")
def __repr__(self) -> str: # pragma: no cover
return f"OpenClawKGTool(base_url={self.base_url!r})"
+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,
},
]

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