Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f163ca24a5 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 | ||
|
|
2ce5067aa3 | ||
|
|
d056e47ab7 | ||
|
|
8eafd2d024 | ||
|
|
7ba93f6772 | ||
|
|
d466203761 | ||
|
|
730dea7911 | ||
|
|
47764c3033 | ||
|
|
055d2fd98d | ||
|
|
ce66681715 | ||
|
|
655b553262 | ||
|
|
60bf8ec75e | ||
|
|
a1478af9c4 | ||
|
|
ee4d6a9188 | ||
|
|
2d00257ae5 | ||
|
|
1cc2f6b93a | ||
|
|
9e26d96b3c | ||
|
|
7267425eb5 | ||
|
|
d9cf7b0088 | ||
|
|
09666806da | ||
|
|
9daddd8186 | ||
|
|
dc8d7ddb03 | ||
|
|
b34634c8b5 | ||
|
|
ee93c4bbe1 | ||
|
|
e4425818e4 | ||
|
|
7b31304e1e | ||
|
|
ab93ec3e8f | ||
|
|
f2eb3e1608 | ||
|
|
898a660ca7 | ||
|
|
fb1e6a6d6e | ||
|
|
0d7b9ca1df | ||
|
|
aca15d3694 | ||
|
|
670027fd22 | ||
|
|
3ea1283626 |
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -0,0 +1 @@
|
||||
# Intialization
|
||||
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||
|
||||
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||
|
||||
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **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**
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[](https://github.com/Hawksight-AI/semantica/actions)
|
||||
[](https://discord.gg/sV34vps5hH)
|
||||
[](https://x.com/BuildSemantica)
|
||||
[](https://openclaw.ai)
|
||||
|
||||
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
|
||||
|
||||
@@ -45,7 +46,7 @@ Semantica is the **context and intelligence layer** you add on top of your exist
|
||||
- ✅ **Reasoning Engines** — forward chaining, Rete networks, deductive, abductive, and SPARQL. Explainable paths, not black boxes.
|
||||
- ✅ **Quality & Deduplication** — conflict detection, entity resolution, and pipeline validation built in.
|
||||
|
||||
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
||||
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
@@ -53,17 +54,314 @@ 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>
|
||||
|
||||
### Agentic Frameworks
|
||||
|
||||
Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th colspan="8" align="left">✅ Supported</th>
|
||||
</tr>
|
||||
<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="48" height="48" /></a><br/>
|
||||
<strong>Agno</strong><br/>
|
||||
<sub>First-class · <code>pip install semantica[agno]</code></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="8" align="left">🔜 Coming Soon</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<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="48" height="48" /></a><br/>
|
||||
<strong>LangChain</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>LangGraph</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>CrewAI</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>LlamaIndex</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>AutoGen</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>OpenAI Agents</strong><br/>
|
||||
<sub>Coming soon</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="48" height="48" /></a><br/>
|
||||
<strong>Google ADK</strong><br/>
|
||||
<sub>Coming soon</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||
>
|
||||
> 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 |
|
||||
|
||||
### 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 +645,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 +942,13 @@ 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
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Integrations
|
||||
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
|
||||
|
||||
### 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 +964,15 @@ 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`
|
||||
|
||||
### Agentic Frameworks
|
||||
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
|
||||
|
||||
> **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
|
||||
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
|
||||
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
||||
- **Ollama** — local models including remote server support
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Semantica v0.3.0 — Release Notes
|
||||
|
||||
**Released:** 2026-03-10
|
||||
**PyPI:** `pip install semantica`
|
||||
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
||||
**Classification:** Production/Stable
|
||||
|
||||
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
| Contributor | Role |
|
||||
|------------|------|
|
||||
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
||||
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
||||
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
||||
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
||||
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — Stable (2026-03-10)
|
||||
|
||||
### Context Graph Feature Completeness
|
||||
|
||||
**Temporal Validity Windows** (by @KaifAhmad1)
|
||||
|
||||
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
||||
|
||||
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
||||
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
||||
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
||||
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
||||
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
||||
|
||||
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
||||
|
||||
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
||||
|
||||
**Cross-Graph Navigation** (by @KaifAhmad1)
|
||||
|
||||
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
||||
|
||||
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
||||
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
||||
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
||||
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
||||
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
||||
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
||||
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
||||
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
||||
|
||||
**Other Fixes** (by @KaifAhmad1)
|
||||
|
||||
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
||||
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
||||
|
||||
**Test Coverage Added**
|
||||
|
||||
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
||||
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-beta — Beta (2026-03-07)
|
||||
|
||||
### Semantic Extraction Fixes
|
||||
|
||||
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
||||
|
||||
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
||||
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
||||
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
||||
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
||||
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
||||
|
||||
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
||||
|
||||
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
||||
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
||||
|
||||
### Incremental / Delta Processing
|
||||
|
||||
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
||||
|
||||
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
||||
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
||||
- Version snapshot management with graph URI tracking and metadata storage.
|
||||
- `prune_versions()` for automatic snapshot retention cleanup.
|
||||
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
||||
|
||||
### Deduplication v2
|
||||
|
||||
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
||||
|
||||
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
||||
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
||||
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
||||
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
||||
|
||||
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
||||
|
||||
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
||||
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
||||
- **18–25% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
|
||||
|
||||
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Canonicalisation engine with predicate synonym mapping (`works_for` → `employed_by`).
|
||||
- O(1) hash matching for exact canonical signatures.
|
||||
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
|
||||
- **6.98x faster** than legacy mode (83ms vs 579ms).
|
||||
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
|
||||
|
||||
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
|
||||
- Full backward compatibility maintained — legacy mode remains the default.
|
||||
|
||||
### Export Formats
|
||||
|
||||
**ArangoDB AQL Export** (PR #342, by @tibisabau)
|
||||
|
||||
- Full AQL INSERT statement generation for vertices and edges.
|
||||
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
|
||||
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
|
||||
- 17 tests, 100% pass rate.
|
||||
|
||||
**Apache Parquet Export** (PR #343, by @tibisabau)
|
||||
|
||||
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
|
||||
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
|
||||
- `export_parquet()` convenience function; `.parquet` auto-detection.
|
||||
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
|
||||
- 25 tests, 100% pass rate.
|
||||
|
||||
### Bug Fixes & Test Suite Stabilisation
|
||||
|
||||
**Test Suite Fixes** (by @KaifAhmad1)
|
||||
|
||||
Context module:
|
||||
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
|
||||
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
|
||||
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
|
||||
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
|
||||
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
|
||||
|
||||
KG module:
|
||||
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
|
||||
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
|
||||
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
|
||||
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
|
||||
|
||||
Pipeline module:
|
||||
- Retry loop fixed — now correctly iterates to `max_retries`.
|
||||
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
|
||||
- `add_step()` fixed to return the created `PipelineStep`.
|
||||
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
|
||||
|
||||
Other:
|
||||
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
|
||||
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
|
||||
- Windows cp1252 encoding fix in test files (emoji → ASCII).
|
||||
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
|
||||
|
||||
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-alpha — Alpha (2026-02-19)
|
||||
|
||||
### Context & Decision Intelligence
|
||||
|
||||
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
|
||||
|
||||
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
|
||||
|
||||
- Full decision lifecycle: `record_decision()` → `add_decision()` → `add_causal_relationship()` → `trace_decision_chain()` → `analyze_decision_impact()` → `analyze_decision_influence()` → `find_similar_decisions()`
|
||||
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
|
||||
- `AgentMemory` with working, conversation, and long-term memory tiers
|
||||
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
|
||||
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
|
||||
- Decision influence analysis via centrality measures and causal chain tracking
|
||||
- GraphStore validation preventing runtime failures; secure logging
|
||||
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
|
||||
|
||||
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
|
||||
|
||||
- Fixed empty/None decision ID handling in `add_decision()`
|
||||
- Fixed None metadata handling preventing `TypeError`
|
||||
- Fixed causal chain depth logic and node exclusion
|
||||
- Fixed nonexistent node handling in `add_causal_relationship()`
|
||||
- Fixed precedent search direction in `find_precedents()`
|
||||
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
|
||||
- Fixed UUID generation across all decision models
|
||||
- All 71 context tests passing
|
||||
|
||||
### Knowledge Graph Algorithms
|
||||
|
||||
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
|
||||
|
||||
- 30+ graph algorithms across 7 categories
|
||||
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
|
||||
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
|
||||
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
|
||||
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
|
||||
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
|
||||
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
|
||||
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
|
||||
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
|
||||
|
||||
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
|
||||
|
||||
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
|
||||
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
|
||||
- `ContextRetriever` with multi-hop reasoning
|
||||
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
|
||||
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
|
||||
|
||||
### Graph Database Backends
|
||||
|
||||
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
|
||||
|
||||
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
|
||||
- SQL injection vulnerabilities fixed with input validation
|
||||
- psycopg2-binary dependency and migration guide added
|
||||
- Fixed parameter replacement and test mock leakage
|
||||
|
||||
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
|
||||
|
||||
- Native PostgreSQL vector storage using the pgvector extension
|
||||
- Multiple distance metrics: cosine, L2/Euclidean, inner product
|
||||
- HNSW and IVFFlat indexing for approximate nearest neighbour search
|
||||
- JSONB metadata storage with flexible filtering; batch operations
|
||||
- Connection pooling with psycopg3/psycopg2 fallback
|
||||
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
|
||||
- 36+ tests with Docker integration
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
|
||||
|
||||
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
|
||||
- Added `ValidationError` when no resources can be allocated
|
||||
- Progress tracking updates moved outside lock scope
|
||||
- 6 regression tests for deadlock prevention
|
||||
|
||||
**Security Configuration** (by @KaifAhmad1)
|
||||
|
||||
- Dependabot bi-weekly security updates with manual review
|
||||
- Automated security scans (Bandit, Safety, Semgrep) on schedule
|
||||
- Security-critical package grouping; zero auto-merge policy
|
||||
|
||||
---
|
||||
|
||||
## Summary by the Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests passing | **886+** |
|
||||
| Test failures | **0** |
|
||||
| Context tests | 335 |
|
||||
| KG tests | ~430 |
|
||||
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
|
||||
| Reasoning tests | 19 |
|
||||
| Real-world scenario tests | 85 |
|
||||
| PyPI classifier | Production/Stable |
|
||||
| Python support | 3.8 – 3.12 |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
```
|
||||
|
||||
No breaking changes. All new parameters have safe defaults and all new methods are additive.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Deduplication & Conflict Resolution Strategies Summary
|
||||
|
||||
## Quick Reference by Use Case
|
||||
|
||||
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
||||
|----------|---------------------|----------------|-------------------|---------------------|
|
||||
| **Finance** |
|
||||
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
||||
| **Biomedical** |
|
||||
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
||||
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
| **Cybersecurity** |
|
||||
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
||||
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
||||
| **Blockchain** |
|
||||
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
||||
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
||||
| **Intelligence** |
|
||||
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
||||
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
||||
| **Renewable Energy** |
|
||||
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| **Supply Chain** |
|
||||
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
|
||||
---
|
||||
|
||||
## Strategy Rationale by Domain
|
||||
|
||||
### Finance
|
||||
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
||||
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
||||
|
||||
### Biomedical
|
||||
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
||||
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
||||
|
||||
### Cybersecurity
|
||||
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
||||
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
||||
|
||||
### Blockchain
|
||||
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
||||
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
||||
|
||||
### Intelligence
|
||||
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
||||
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
||||
|
||||
### Renewable Energy
|
||||
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
||||
|
||||
### Supply Chain
|
||||
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
||||
|
||||
---
|
||||
|
||||
## Method Distribution
|
||||
|
||||
### Deduplication Methods (9 total)
|
||||
- `pairwise`: 2 notebooks (real-time processing)
|
||||
- `batch`: 3 notebooks (large datasets)
|
||||
- `incremental`: 2 notebooks (streaming/continuous)
|
||||
- `group`: 2 notebooks (related entities)
|
||||
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
||||
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
||||
- `exact` (EntityResolver): 1 notebook (exact matching)
|
||||
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
||||
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
||||
|
||||
### Merge Strategies (5 total)
|
||||
- `keep_first`: 1 notebook (first detection priority)
|
||||
- `keep_last`: 1 notebook (latest information)
|
||||
- `keep_most_complete`: 5 notebooks (preserve all details)
|
||||
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
||||
- `merge_all`: 3 notebooks (combine all information)
|
||||
|
||||
### Conflict Detection Methods (6 total)
|
||||
- `value`: 4 notebooks (property value conflicts)
|
||||
- `type`: 2 notebooks (type/classification conflicts)
|
||||
- `entity`: 2 notebooks (entity-wide conflicts)
|
||||
- `relationship`: 3 notebooks (relationship conflicts)
|
||||
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
||||
- `logical`: 2 notebooks (logical inconsistencies)
|
||||
|
||||
### Conflict Resolution Strategies (6 total)
|
||||
- `voting`: 5 notebooks (majority vote)
|
||||
- `credibility_weighted`: 4 notebooks (source credibility)
|
||||
- `most_recent`: 3 notebooks (latest data)
|
||||
- `first_seen`: 1 notebook (first detection)
|
||||
- `highest_confidence`: 2 notebooks (most confident)
|
||||
- `expert_review`: 1 notebook (manual review)
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
||||
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
||||
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
||||
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
||||
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
||||
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -14,6 +14,8 @@ export type PathResponse = {
|
||||
path: string[];
|
||||
edge_ids?: string[];
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
@@ -22,11 +24,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 +41,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 +187,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 +211,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 +250,7 @@ export function GraphInspectorPanel({
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Trace Path */}
|
||||
<section style={sectionStyle}>
|
||||
<div style={sectionTitleStyle}>Trace Path</div>
|
||||
<input
|
||||
@@ -166,20 +260,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 +287,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 +328,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 +341,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 +349,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 +365,8 @@ export function GraphInspectorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
@@ -284,19 +403,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 +450,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()}
|
||||
@@ -33,6 +33,8 @@ type LinkPrediction = {
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
@@ -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: '/',
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Entry point: python -m mcp.server"""
|
||||
from mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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"]
|
||||
@@ -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)}),
|
||||
}
|
||||
@@ -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": {}}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||