diff --git a/.claude/init b/.claude/init
new file mode 100644
index 00000000..7597c975
--- /dev/null
+++ b/.claude/init
@@ -0,0 +1 @@
+# Initialization
diff --git a/.claude/skills/init b/.claude/skills/init
new file mode 100644
index 00000000..e8e95c54
--- /dev/null
+++ b/.claude/skills/init
@@ -0,0 +1 @@
+# Intialization
diff --git a/.claude/skills/semantica/init b/.claude/skills/semantica/init
new file mode 100644
index 00000000..7597c975
--- /dev/null
+++ b/.claude/skills/semantica/init
@@ -0,0 +1 @@
+# Initialization
diff --git a/.gitignore b/.gitignore
index 97b95c1b..c53c2817 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,5 +111,9 @@ sample_data/
# Test Results
test_results.txt
+# Frontend workspace artifacts
+semantica-explorer/
+node_modules/
+
# Frontend build artifacts (generated by Vite — do not track in git)
semantica/static/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6b6c43f..c1bc150c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
+ - **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
+ - **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
+ - **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
+ - **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
+ - **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
+ - **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
+ - **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
+ - 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
+
+- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
+ - **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24–471 ms; a session with 500 k nodes was effectively unusable.
+ - **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
+ - **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
+ - **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
+ - **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
+ - **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
+ - **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
+ - 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
+ - 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
+- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
+ - **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
+ - **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
+ - **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
+ - **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
+ - **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
+ - 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
+
+- **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**
diff --git a/README.md b/README.md
index 436762fa..1eaec608 100644
--- a/README.md
+++ b/README.md
@@ -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
+
-See the community setup guide:
+
+
+🔌 Native Plugin Bundle
+⚡ MCP Server + Plugin
+
+
+
+
+Claude Code
+17 skills · 3 agents · hooks
+
+
+
+Cursor
+17 skills · 3 agents
+
+
+
+Codex CLI
+17 skills · 3 agents
+
+
+
+Windsurf
+plugin
+
+
+
+Cline
+plugin
+
+
+
+Continue
+plugin
+
+
+
+VS Code
+plugin
+
+
+
+OpenClaw
+MCP + plugin
+
+
-- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
+
+
+☁️ MCP Server
+🌐 REST API
+
+
+
+
+Claude Desktop
+MCP server
+
+
+
+GitHub Copilot
+REST API
+
+
+
+Roo Code
+REST API
+
+
+
+Goose
+REST API
+
+
+
+Kilo Code
+REST API
+
+
+
+Aider
+REST API
+
+
+
+Amazon Q
+REST API
+
+
+
+Zed
+REST API
+
+
+
+
+
+🔧 Any Tool
+
+
+
+
+Any agent
+109 REST endpoints · FastAPI · port 8000
+
+
+
+
+
+### Agentic Frameworks
+
+Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
+
+
+
+✅ Supported
+
+
+
+
+Agno
+First-class · pip install semantica[agno]
+
+
+
+🔜 Coming Soon
+
+
+
+
+LangChain
+Coming soon
+
+
+
+LangGraph
+Coming soon
+
+
+
+CrewAI
+Coming soon
+
+
+
+LlamaIndex
+Coming soon
+
+
+
+AutoGen
+Coming soon
+
+
+
+OpenAI Agents
+Coming soon
+
+
+
+Google ADK
+Coming soon
+
+
+
+
+> **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
---
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
deleted file mode 100644
index a028313a..00000000
--- a/RELEASE_NOTES.md
+++ /dev/null
@@ -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.
diff --git a/STRATEGIES_SUMMARY.md b/STRATEGIES_SUMMARY.md
deleted file mode 100644
index 799bd01e..00000000
--- a/STRATEGIES_SUMMARY.md
+++ /dev/null
@@ -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)
-
diff --git a/semantica-explorer/.gitignore b/explorer/.gitignore
similarity index 100%
rename from semantica-explorer/.gitignore
rename to explorer/.gitignore
diff --git a/explorer/README.md b/explorer/README.md
new file mode 100644
index 00000000..2bb6b2c4
--- /dev/null
+++ b/explorer/README.md
@@ -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).
diff --git a/semantica-explorer/eslint.config.js b/explorer/eslint.config.js
similarity index 100%
rename from semantica-explorer/eslint.config.js
rename to explorer/eslint.config.js
diff --git a/semantica-explorer/index.html b/explorer/index.html
similarity index 87%
rename from semantica-explorer/index.html
rename to explorer/index.html
index 5ec53a70..b2f08fac 100644
--- a/semantica-explorer/index.html
+++ b/explorer/index.html
@@ -4,7 +4,7 @@
- semantica-explorer
+ Semantica Knowledge Explorer
diff --git a/semantica-explorer/package-lock.json b/explorer/package-lock.json
similarity index 80%
rename from semantica-explorer/package-lock.json
rename to explorer/package-lock.json
index 64e09c6f..1f057188 100644
--- a/semantica-explorer/package-lock.json
+++ b/explorer/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "semantica-explorer",
+ "name": "semantica-knowledge-explorer",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "semantica-explorer",
+ "name": "semantica-knowledge-explorer",
"version": "0.0.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -30,12 +30,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",
@@ -44,7 +43,7 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^5.4.0"
}
},
"node_modules/@babel/code-frame": {
@@ -179,6 +178,16 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -239,6 +248,38 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
@@ -309,43 +350,6 @@
"node": ">=0.8.0"
}
},
- "node_modules/@emnapi/core": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
- "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
- "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -946,29 +950,43 @@
}
},
"node_modules/@humanfs/core": {
- "version": "0.19.1",
- "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
- "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@humanfs/core": "^0.19.1",
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -1070,35 +1088,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
- "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.1"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.122.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
- "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
@@ -1117,10 +1106,31 @@
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
"license": "MIT"
},
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -1129,15 +1139,12 @@
"optional": true,
"os": [
"android"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -1146,15 +1153,12 @@
"optional": true,
"os": [
"darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
- "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -1163,15 +1167,26 @@
"optional": true,
"os": [
"darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
- "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -1180,15 +1195,12 @@
"optional": true,
"os": [
"freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
- "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -1197,15 +1209,26 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -1214,15 +1237,12 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
- "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -1231,15 +1251,40 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
@@ -1248,15 +1293,54 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -1265,15 +1349,12 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
- "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -1282,15 +1363,12 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
- "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -1299,15 +1377,26 @@
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
- "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -1316,32 +1405,12 @@
"optional": true,
"os": [
"openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
- "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@napi-rs/wasm-runtime": "^1.1.1"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
- "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -1350,15 +1419,26 @@
"optional": true,
"os": [
"win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
- "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -1367,48 +1447,21 @@
"optional": true,
"os": [
"win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/plugin-babel": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.2.tgz",
- "integrity": "sha512-q9pE8+47bQNHb5eWVcE6oXppA+JTSwvnrhH53m0ZuHuK5MLvwsLoWrWzBTFQqQ06BVxz1gp0HblLsch8o6pvZw==",
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
"license": "MIT",
- "dependencies": {
- "picomatch": "^4.0.3"
- },
- "engines": {
- "node": ">=22.12.0 || ^24.0.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.29.0 || ^8.0.0-rc.1",
- "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1",
- "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1",
- "rolldown": "^1.0.0-rc.5",
- "vite": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "@babel/plugin-transform-runtime": {
- "optional": true
- },
- "@babel/runtime": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.7",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
- "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
- "dev": true,
- "license": "MIT"
+ "optional": true,
+ "os": [
+ "win32"
+ ]
},
"node_modules/@sigma/edge-curve": {
"version": "3.1.0",
@@ -1429,9 +1482,9 @@
}
},
"node_modules/@tanstack/query-core": {
- "version": "5.95.2",
- "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
- "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz",
+ "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1439,12 +1492,12 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.95.2",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
- "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
+ "version": "5.99.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz",
+ "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==",
"license": "MIT",
"dependencies": {
- "@tanstack/query-core": "5.95.2"
+ "@tanstack/query-core": "5.99.2"
},
"funding": {
"type": "github",
@@ -1454,17 +1507,6 @@
"react": "^18 || ^19"
}
},
- "node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1581,9 +1623,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.12.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
- "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
+ "version": "24.12.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -1619,20 +1661,20 @@
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
- "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
+ "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/type-utils": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/type-utils": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1642,9 +1684,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.57.2",
+ "@typescript-eslint/parser": "^8.58.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -1658,16 +1700,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
- "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz",
+ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -1679,18 +1721,18 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
- "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz",
+ "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.57.2",
- "@typescript-eslint/types": "^8.57.2",
+ "@typescript-eslint/tsconfig-utils": "^8.58.2",
+ "@typescript-eslint/types": "^8.58.2",
"debug": "^4.4.3"
},
"engines": {
@@ -1701,18 +1743,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
- "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz",
+ "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2"
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1723,9 +1765,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
- "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz",
+ "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1736,21 +1778,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
- "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz",
+ "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2",
"debug": "^4.4.3",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1761,13 +1803,13 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
- "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz",
+ "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1779,21 +1821,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
- "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz",
+ "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.57.2",
- "@typescript-eslint/tsconfig-utils": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/visitor-keys": "8.57.2",
+ "@typescript-eslint/project-service": "8.58.2",
+ "@typescript-eslint/tsconfig-utils": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/visitor-keys": "8.58.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.4.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1803,7 +1845,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
@@ -1830,13 +1872,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "10.2.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
- "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^5.0.2"
+ "brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -1859,16 +1901,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
- "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz",
+ "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.57.2",
- "@typescript-eslint/types": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2"
+ "@typescript-eslint/scope-manager": "8.58.2",
+ "@typescript-eslint/types": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1879,17 +1921,17 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
- "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz",
+ "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.57.2",
+ "@typescript-eslint/types": "8.58.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -1914,29 +1956,24 @@
}
},
"node_modules/@vitejs/plugin-react": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
- "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "1.0.0-rc.7"
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
- "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
- "babel-plugin-react-compiler": "^1.0.0",
- "vite": "^8.0.0"
- },
- "peerDependenciesMeta": {
- "@rolldown/plugin-babel": {
- "optional": true
- },
- "babel-plugin-react-compiler": {
- "optional": true
- }
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xyflow/react": {
@@ -2067,9 +2104,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.12",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
- "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
+ "version": "2.10.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
+ "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2080,9 +2117,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
- "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2091,9 +2128,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
@@ -2111,11 +2148,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -2135,9 +2172,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001781",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
- "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "version": "1.0.30001788",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+ "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"dev": true,
"funding": [
{
@@ -2388,16 +2425,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/dnd-core": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
@@ -2429,9 +2456,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.328",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
- "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==",
+ "version": "1.5.340",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz",
+ "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==",
"dev": true,
"license": "ISC"
},
@@ -2561,9 +2588,9 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
- "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2577,7 +2604,7 @@
"node": ">=18"
},
"peerDependencies": {
- "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
@@ -2846,9 +2873,9 @@
}
},
"node_modules/globals": {
- "version": "17.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
- "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
+ "version": "17.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz",
+ "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3157,267 +3184,6 @@
"node": ">= 0.8.0"
}
},
- "node_modules/lightningcss": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
- "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.32.0",
- "lightningcss-darwin-arm64": "1.32.0",
- "lightningcss-darwin-x64": "1.32.0",
- "lightningcss-freebsd-x64": "1.32.0",
- "lightningcss-linux-arm-gnueabihf": "1.32.0",
- "lightningcss-linux-arm64-gnu": "1.32.0",
- "lightningcss-linux-arm64-musl": "1.32.0",
- "lightningcss-linux-x64-gnu": "1.32.0",
- "lightningcss-linux-x64-musl": "1.32.0",
- "lightningcss-win32-arm64-msvc": "1.32.0",
- "lightningcss-win32-x64-msvc": "1.32.0"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
- "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
- "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
- "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
- "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
- "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
- "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
- "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
- "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
- "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
- "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3464,9 +3230,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
- "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -3568,9 +3334,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.36",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
- "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "version": "2.0.37",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
+ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"dev": true,
"license": "MIT"
},
@@ -3702,9 +3468,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -3772,9 +3538,9 @@
}
},
"node_modules/react": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
- "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3837,15 +3603,15 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.4",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
- "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.4"
+ "react": "^19.2.5"
}
},
"node_modules/react-dropzone": {
@@ -3871,6 +3637,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
@@ -3914,47 +3690,51 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
- "node_modules/rolldown": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
- "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.122.0",
- "@rolldown/pluginutils": "1.0.0-rc.12"
+ "@types/estree": "1.0.8"
},
"bin": {
- "rolldown": "bin/cli.mjs"
+ "rollup": "dist/bin/rollup"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.12",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.12",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
}
},
- "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.12",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
- "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4047,14 +3827,14 @@
}
},
"node_modules/tinyglobby": {
- "version": "0.2.15",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
- "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
- "picomatch": "^4.0.3"
+ "picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -4130,16 +3910,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.57.2",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
- "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
+ "version": "8.58.2",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz",
+ "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.57.2",
- "@typescript-eslint/parser": "8.57.2",
- "@typescript-eslint/typescript-estree": "8.57.2",
- "@typescript-eslint/utils": "8.57.2"
+ "@typescript-eslint/eslint-plugin": "8.58.2",
+ "@typescript-eslint/parser": "8.58.2",
+ "@typescript-eslint/typescript-estree": "8.58.2",
+ "@typescript-eslint/utils": "8.58.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4150,7 +3930,7 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici-types": {
@@ -4278,23 +4058,21 @@
}
},
"node_modules/vite": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
- "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "lightningcss": "^1.32.0",
- "picomatch": "^4.0.4",
- "postcss": "^8.5.8",
- "rolldown": "1.0.0-rc.12",
- "tinyglobby": "^0.2.15"
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -4303,35 +4081,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
- "esbuild": "^0.27.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
- "@vitejs/devtools": {
- "optional": true
- },
- "esbuild": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
"less": {
"optional": true
},
+ "lightningcss": {
+ "optional": true
+ },
"sass": {
"optional": true
},
@@ -4346,15 +4114,439 @@
},
"terser": {
"optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
}
}
},
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/semantica-explorer/package.json b/explorer/package.json
similarity index 91%
rename from semantica-explorer/package.json
rename to explorer/package.json
index fb698fd5..23df2b07 100644
--- a/semantica-explorer/package.json
+++ b/explorer/package.json
@@ -1,5 +1,5 @@
{
- "name": "semantica-explorer",
+ "name": "semantica-knowledge-explorer",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -34,12 +34,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",
@@ -48,6 +47,6 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
- "vite": "^8.0.1"
+ "vite": "^5.4.0"
}
}
diff --git a/semantica-explorer/public/favicon.svg b/explorer/public/favicon.svg
similarity index 100%
rename from semantica-explorer/public/favicon.svg
rename to explorer/public/favicon.svg
diff --git a/semantica-explorer/public/icons.svg b/explorer/public/icons.svg
similarity index 100%
rename from semantica-explorer/public/icons.svg
rename to explorer/public/icons.svg
diff --git a/semantica-explorer/src/App.css b/explorer/src/App.css
similarity index 100%
rename from semantica-explorer/src/App.css
rename to explorer/src/App.css
diff --git a/semantica-explorer/src/App.tsx b/explorer/src/App.tsx
similarity index 81%
rename from semantica-explorer/src/App.tsx
rename to explorer/src/App.tsx
index 1e44b247..17d8bc78 100644
--- a/semantica-explorer/src/App.tsx
+++ b/explorer/src/App.tsx
@@ -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 (
-
Workspace
+
{kicker}
{title}
{subtitle ?
{subtitle}
: null}
@@ -309,6 +316,7 @@ export default function App() {
const [exploreView, setExploreView] = useState
('graph');
const [analyzeView, setAnalyzeView] = useState('reasoning');
const [enrichView, setEnrichView] = useState('import');
+ const [manageView, setManageView] = useState('lineage');
const renderWorkspace = () => {
if (activeWorkspace === 'explore') {
@@ -316,6 +324,7 @@ export default function App() {
@@ -340,6 +349,7 @@ export default function App() {
setAnalyzeView('reasoning')}>
@@ -363,6 +373,7 @@ export default function App() {
}>
@@ -375,7 +386,8 @@ export default function App() {
return (
setEnrichView('import')}>
@@ -384,11 +396,20 @@ export default function App() {
setEnrichView('merge')}>
Diff and Merge
+ setEnrichView('resolve')}>
+ Entity Resolution
+
+ setEnrichView('registry')}>
+ Registry
+
>
}
>
}>
- {enrichView === 'import' ? : }
+ {enrichView === 'import' ? :
+ enrichView === 'merge' ? :
+ enrichView === 'resolve' ? :
+ }
);
@@ -397,10 +418,29 @@ export default function App() {
return (
+ setManageView('lineage')}>
+ PROV-O Lineage
+
+ setManageView('kg-overview')}>
+ KG Overview
+
+ setManageView('ontology')}>
+ Ontology Summary
+
+ >
+ }
>
}>
-
+ {manageView === 'lineage' ? :
+ manageView === 'kg-overview' ? :
+ {
+ setActiveWorkspace('explore');
+ setExploreView('vocabulary');
+ }} />}
);
@@ -411,7 +451,7 @@ export default function App() {
- SEM
+ SKE
{navItems.map(({ id, label, hint, icon: Icon }) => (
;
+}
+
+type Listener = (entries: readonly RegistryEntry[]) => void;
+
+let _entries: RegistryEntry[] = [];
+const _listeners = new Set();
+const MAX_ENTRIES = 500;
+
+function _notify(): void {
+ _listeners.forEach((fn) => fn(_entries));
+}
+
+export function logEvent(
+ op: RegistryEntryOp,
+ summary: string,
+ detail?: Record,
+): 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(_entries);
+ useEffect(() => {
+ // Sync any events that arrived between render and subscribe
+ setSnapshot(_entries);
+ _listeners.add(setSnapshot);
+ return () => {
+ _listeners.delete(setSnapshot);
+ };
+ }, []);
+ return snapshot;
+}
diff --git a/semantica-explorer/src/types.d.ts b/explorer/src/types.d.ts
similarity index 100%
rename from semantica-explorer/src/types.d.ts
rename to explorer/src/types.d.ts
diff --git a/semantica-explorer/src/ui/primitives.css b/explorer/src/ui/primitives.css
similarity index 100%
rename from semantica-explorer/src/ui/primitives.css
rename to explorer/src/ui/primitives.css
diff --git a/semantica-explorer/src/ui/primitives.tsx b/explorer/src/ui/primitives.tsx
similarity index 100%
rename from semantica-explorer/src/ui/primitives.tsx
rename to explorer/src/ui/primitives.tsx
diff --git a/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx b/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
new file mode 100644
index 00000000..974819e3
--- /dev/null
+++ b/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
@@ -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 (
+
+ {outcome || "unknown"}
+
+ );
+}
+
+function SkeletonList() {
+ return (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ );
+}
+
+/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
+
+interface ChainStep {
+ id: string;
+ relationship: string;
+ content?: string;
+ type?: string;
+ [key: string]: unknown;
+}
+
+function RelationshipPill({ label }: { label: string }) {
+ return (
+
+ {/* Connector line top */}
+
+ {/* Pill */}
+
+ {label}
+
+ {/* Connector line bottom + arrow */}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {step.type ? (
+
+ {step.type}
+
+ ) : null}
+
+
+ {step.content || step.id}
+
+ {step.id && step.id !== step.content ? (
+
{step.id}
+ ) : null}
+
+ );
+}
+
+function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (chain.length === 0) {
+ return (
+
+ No causal chain steps found for this decision.
+
+ );
+ }
+
+ return (
+
+ {chain.map((step, index) => (
+
+
+ {index < chain.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+/* ─── Main Workspace ──────────────────────────────────────────────── */
+
+export function DecisionWorkspace() {
+ const [decisions, setDecisions] = useState([]);
+ const [selectedDecision, setSelectedDecision] = useState(null);
+ const [chain, setChain] = useState([]);
+ 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 (
+
+
+
+ {/* Left Column — Decision List */}
+
+ {/* List header */}
+
+
+
+
Decisions
+ {decisions.length > 0 ? (
+ {decisions.length}
+ ) : null}
+
+
+ {/* Filter input */}
+
+
+ setFilterQuery(e.target.value)}
+ style={filterInputStyle}
+ />
+
+
+
+ {/* Decision list */}
+
+ {listLoading ? (
+
+ ) : filteredDecisions.length === 0 ? (
+
+ {decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
+
+ ) : (
+
+ {filteredDecisions.map((d) => {
+ const isActive = selectedDecision?.decision_id === d.decision_id;
+ return (
+
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",
+ }}
+ >
+ {d.decision_id}
+
+ {d.category ? (
+ {d.category}
+ ) : null}
+ {d.outcome ? : null}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Right Column — Decision Detail */}
+
+ {/* Radial accent */}
+
+
+ {selectedDecision ? (
+
+ {/* Decision header */}
+
+
+
+
+ Decision ID
+
+
+ {selectedDecision.decision_id}
+
+
+ {selectedDecision.outcome ?
: null}
+
+
+ {selectedDecision.category ? (
+
+ {selectedDecision.category}
+
+ ) : null}
+
+
+ {/* Causal Chain */}
+
+
+
+
+ Causal Chain
+
+ {chain.length > 0 && !loading ? (
+
+ {chain.length} step{chain.length !== 1 ? "s" : ""}
+
+ ) : null}
+
+
+
+
+ ) : (
+
+ Select a decision to inspect its causal chain.
+
+ )}
+
+
+ );
+}
+
+/* ─── 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",
+};
diff --git a/semantica-explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx b/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
similarity index 93%
rename from semantica-explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
rename to explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
index 1d71c33c..e27e758f 100644
--- a/semantica-explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
+++ b/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
@@ -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...");
}
diff --git a/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx b/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
new file mode 100644
index 00000000..5d1c9151
--- /dev/null
+++ b/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
@@ -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;
+ entity_b?: string | Record;
+ similarity?: number;
+ score?: number;
+ [key: string]: unknown;
+}
+
+function extractId(entity: string | Record | 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 | 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 | 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),
+ label: extractLabel(item.entity_a as string | Record),
+ type: extractType(item.entity_a as string | Record),
+ },
+ b: {
+ id: extractId(item.entity_b as string | Record),
+ label: extractLabel(item.entity_b as string | Record),
+ type: extractType(item.entity_b as string | Record),
+ },
+ 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 (
+
+ );
+}
+
+function PairRow({
+ pair,
+ onMerge,
+ onDismiss,
+}: {
+ pair: DedupPair;
+ onMerge: (primaryId: string, duplicateId: string) => Promise;
+ 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 (
+
+
+ {/* Expand */}
+
setExpanded((v) => !v)} style={iconBtnStyle}>
+ {expanded ? : }
+
+
+ {/* Entity Labels */}
+
+
+ {pair.a.label || pair.a.id}
+ ≈
+ {pair.b.label || pair.b.id}
+
+
+
+
+
+
+ {/* Actions */}
+
+ 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 ? : }
+ Merge
+
+
+
+
+
+
+
+ {/* Expanded diff */}
+ {expanded ? (
+
+ {[
+ { label: "Primary (keep)", entity: pair.a, accentColor: "#4aa3ff" },
+ { label: "Duplicate (remove)", entity: pair.b, accentColor: "#ff7b72" },
+ ].map(({ label, entity, accentColor }) => (
+
+
+ {label}
+
+
{entity.label || entity.id}
+
{entity.type}
+
{entity.id}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function EntityResolutionTab() {
+ const [threshold, setThreshold] = useState(0.82);
+ const [scanning, setScanning] = useState(false);
+ const [pairs, setPairs] = useState([]);
+ 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).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 (
+
+ {/* Header */}
+
+
+
+
+
Entity Resolution
+
Detect and merge duplicate entities in the knowledge graph
+
+
+
+
+ {/* Scan controls */}
+
+
+
+
+ Similarity Threshold
+ {threshold.toFixed(2)}
+
+
setThreshold(parseFloat(e.target.value))}
+ style={{ width: "100%", accentColor: "#f2b66d", cursor: "pointer" }}
+ />
+
+ More results (0.50)
+ Fewer, higher confidence (0.99)
+
+
+
void handleScan()}
+ disabled={scanning}
+ style={scanBtnStyle}
+ >
+ {scanning ? : }
+ {scanning ? "Scanning…" : "Run Dedup Scan"}
+
+
+ {scanError ? (
+
{scanError}
+ ) : null}
+
+
+
+ {/* Flagged pairs */}
+
+ {pairs.length > 0 ? (
+ <>
+
+
+ {pairs.length} flagged pair{pairs.length !== 1 ? "s" : ""}
+
+
setPairs([])} style={clearAllBtnStyle}>Clear all
+
+ {pairs.map((pair, index) => (
+
handleDismiss(index)}
+ />
+ ))}
+ >
+ ) : (
+
+
+
+ No flagged pairs
+
+
+ Set a similarity threshold and run a dedup scan to detect potential duplicates.
+
+
+ )}
+
+
+ {/* Merge history sidebar */}
+ {mergeHistory.length > 0 ? (
+
+
+ Merge History
+
+
+ {mergeHistory.map((entry) => (
+
+
+
+
+ {entry.summary}
+
+
+ {entry.timestamp.toLocaleTimeString()}
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+ );
+}
+
+/* ─── 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,
+};
diff --git a/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx b/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx
new file mode 100644
index 00000000..e1178eaa
--- /dev/null
+++ b/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx
@@ -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[number] }) {
+ const [expanded, setExpanded] = useState(false);
+ const meta = OP_META[entry.op];
+ const hasDetail = entry.detail && Object.keys(entry.detail).length > 0;
+
+ return (
+
+
+ {/* Op Badge */}
+
+ {meta.label}
+
+
+ {/* Content */}
+
+
+ {entry.summary}
+
+
+ {formatDate(entry.timestamp)} · {formatTimestamp(entry.timestamp)}
+
+
+
+ {/* Expand toggle */}
+ {hasDetail ? (
+
setExpanded((v) => !v)}
+ title={expanded ? "Collapse details" : "Expand details"}
+ style={expandBtnStyle}
+ >
+ {expanded ? : }
+
+ ) : null}
+
+
+ {/* Expanded detail */}
+ {expanded && hasDetail ? (
+
+ {JSON.stringify(entry.detail, null, 2)}
+
+ ) : null}
+
+ );
+}
+
+export function RegistryTab() {
+ const entries = useRegistry();
+ const [activeFilter, setActiveFilter] = useState("all");
+
+ const filtered = activeFilter === "all"
+ ? entries
+ : entries.filter((e) => e.op === activeFilter);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Document Registry
+
+ Audit log of all KG and Ontology mutations this session
+
+
+
+
+
+ {entries.length} event{entries.length !== 1 ? "s" : ""}
+
+ {entries.length > 0 ? (
+
+
+ Clear
+
+ ) : null}
+
+
+
+ {/* Filter pills */}
+
+
+
+ {ALL_OPS.map((op) => {
+ const isActive = op === activeFilter;
+ const meta = op === "all" ? null : OP_META[op as RegistryEntryOp];
+ return (
+ 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)}
+
+ );
+ })}
+
+
+
+ {/* Feed */}
+
+ {filtered.length === 0 ? (
+
+
+
+ No events recorded yet
+
+
+ Import a file, run reasoning, or merge entities to see activity appear here.
+
+
+ ) : (
+ filtered.map((entry) =>
)
+ )}
+
+
+ );
+}
+
+/* ─── 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,
+};
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
similarity index 99%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
index ab3092d8..216b4142 100644
--- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx
@@ -116,13 +116,15 @@ const SIGMA_SETTINGS = {
labelRenderedSizeThreshold: 6,
defaultNodeType: "circle",
defaultEdgeType: "line",
- hideLabelsOnMove: true,
- hideEdgesOnMove: true,
+ hideLabelsOnMove: false,
+ hideEdgesOnMove: false,
enableEdgeEvents: true,
renderEdgeLabels: false,
labelDensity: 0.7,
labelGridCellSize: 140,
zIndex: true,
+ minCameraRatio: 0.04,
+ maxCameraRatio: 8,
webGLTarget: "webgl2" as const,
nodeProgramClasses: SEMANTICA_NODE_PROGRAM_CLASSES,
edgeProgramClasses: SEMANTICA_EDGE_PROGRAM_CLASSES,
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
similarity index 50%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
index c7150ac4..df30b0e5 100644
--- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
@@ -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) {
.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 No path found between the selected nodes.
;
+ }
+
+ return (
+
+ {/* Horizontal scrollable chip flow */}
+
+ {path.map((nodeId, index) => {
+ const label = getNodeLabel(nodeId);
+ const edgeLabel =
+ index < path.length - 1
+ ? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds)
+ : null;
+
+ return (
+
+ {/* Node chip */}
+
onFocusNode?.(nodeId)}
+ title={`Focus: ${nodeId}`}
+ style={{
+ ...pathNodeChipStyle,
+ cursor: onFocusNode ? "pointer" : "default",
+ }}
+ >
+ {index + 1}
+
+ {label}
+
+
+
+ {/* Edge connector */}
+ {edgeLabel !== null ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ {/* Weight badge */}
+
+ Total weight:
+ {totalWeight.toFixed(3)}
+ ·
+ {path.length} hops
+
+
+ );
+}
+
+/* ─── Main Panel ─────────────────────────────────────────────────── */
+
export function GraphInspectorPanel({
nodeId,
predictions,
predictionType,
onPredictionTypeChange,
onRunPredictions,
+ isRunningPredictions = false,
pathTargetId,
onPathTargetChange,
onTracePath,
pathResult,
onDownloadProvenance,
+ onFocusNode,
}: GraphInspectorPanelProps) {
if (!nodeId) {
return (
-
-
+
+
+
Search for a node or click one in the canvas to inspect its properties.
@@ -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 (
+ {/* Node identity */}
-
+
{attributes?.nodeType || "Entity"}
{String(attributes?.label ?? nodeId)}
-
{nodeId}
+
{nodeId}
{attributes?.valid_from || attributes?.valid_until ? (
temporal
@@ -117,28 +211,27 @@ export function GraphInspectorPanel({
- {(attributes?.valid_from || attributes?.valid_until) && (
-
+ {/* Temporal bounds */}
+ {(attributes?.valid_from || attributes?.valid_until) ? (
+
{attributes?.valid_from ?
from: {attributes.valid_from}
: null}
{attributes?.valid_until ?
until: {attributes.valid_until}
: null}
- )}
+ ) : null}
+ {/* Actions */}
Actions
-
- Run Link Prediction
+
+ {isRunningPredictions ? (
+
+ ) : null}
+ {isRunningPredictions ? "Running…" : "Run Link Prediction"}
onDownloadProvenance("json")}>
@@ -157,6 +250,7 @@ export function GraphInspectorPanel({
/>
+ {/* Trace Path */}
Trace Path
Trace Causal Path
+
{pathResult?.path?.length ? (
-
- {pathResult.path.map((step, index) => (
-
{index + 1}. {step}
- ))}
-
- total weight: {pathResult.total_weight.toFixed(3)}
-
-
+
) : (
- Choose a target or click a candidate prediction to prepare a path trace.
+
+ Choose a target or click a candidate prediction to prepare a path trace.
+
)}
+ {/* Candidate Links */}
0}>
Candidate Links
@@ -191,20 +287,40 @@ export function GraphInspectorPanel({
style={predictionCardStyle}
onClick={() => onPathTargetChange(prediction.target)}
>
-
{prediction.label || prediction.target}
-
{prediction.type}
-
- confidence {prediction.score.toFixed(3)}
+
+
+
{prediction.label || prediction.target}
+
{prediction.type}
+
+
+
+ {(prediction.score * 100).toFixed(1)}%
+
+
))}
+ ) : isRunningPredictions ? (
+
+
+ Computing candidate links…
+
) : (
Run link prediction to surface likely next-hop relationships.
)}
+ {/* Source Attribution */}
Source Attribution
@@ -212,7 +328,7 @@ export function GraphInspectorPanel({
{attribution.map(({ key, value }) => (
-
{key}
+
{key}
{typeof value === "object" ? JSON.stringify(value) : String(value)}
@@ -225,6 +341,7 @@ export function GraphInspectorPanel({
+ {/* Properties */}
Properties
@@ -232,7 +349,7 @@ export function GraphInspectorPanel({
{propertyEntries.map(([key, value]) => (
-
{key}
+
{key}
{typeof value === "object" ? JSON.stringify(value) : String(value)}
@@ -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",
+};
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx b/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx b/explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphRuntimeStage.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
similarity index 97%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
index 833a6954..48eeef03 100644
--- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
@@ -1,7 +1,8 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type Graph from "graphology";
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";
@@ -676,6 +677,7 @@ export function GraphWorkspace() {
const [searchResults, setSearchResults] = useState
([]);
const [searchError, setSearchError] = useState("");
const [predictionType, setPredictionType] = useState("");
+ const [isRunningPredictions, setIsRunningPredictions] = useState(false);
const [predictions, setPredictions] = useState([]);
const [pathTargetId, setPathTargetId] = useState("");
const [pathResult, setPathResult] = useState(null);
@@ -882,6 +884,7 @@ export function GraphWorkspace() {
const handleRunPredictions = useCallback(async () => {
if (!selectedNodeId) return;
+ setIsRunningPredictions(true);
try {
const response = await fetch("/api/enrich/links", {
method: "POST",
@@ -901,6 +904,8 @@ export function GraphWorkspace() {
} catch (predictionError) {
console.error("[GraphWorkspace] prediction failed", predictionError);
setPredictions([]);
+ } finally {
+ setIsRunningPredictions(false);
}
}, [predictionType, selectedNodeId]);
@@ -967,6 +972,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") {
@@ -979,6 +985,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) {
@@ -1434,10 +1441,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(),
},
{
@@ -1748,6 +1779,7 @@ export function GraphWorkspace() {
predictionType={predictionType}
onPredictionTypeChange={setPredictionType}
onRunPredictions={() => void handleRunPredictions()}
+ isRunningPredictions={isRunningPredictions}
pathTargetId={pathTargetId}
onPathTargetChange={setPathTargetId}
onTracePath={() => void handleTracePath()}
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx
similarity index 99%
rename from semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx
rename to explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx
index b01f2713..0af7d5f1 100644
--- a/semantica-explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspaceShell.tsx
@@ -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 = {
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx b/explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx
rename to explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx b/explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx
rename to explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/types.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/types.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/types.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts b/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts
rename to explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts b/explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts
rename to explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphConfig.ts b/explorer/src/workspaces/GraphWorkspace/graphConfig.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphConfig.ts
rename to explorer/src/workspaces/GraphWorkspace/graphConfig.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphLoading.ts b/explorer/src/workspaces/GraphWorkspace/graphLoading.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphLoading.ts
rename to explorer/src/workspaces/GraphWorkspace/graphLoading.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts b/explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts
rename to explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
rename to explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts b/explorer/src/workspaces/GraphWorkspace/graphTheme.ts
similarity index 92%
rename from semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts
rename to explorer/src/workspaces/GraphWorkspace/graphTheme.ts
index 6afed942..6d6e8968 100644
--- a/semantica-explorer/src/workspaces/GraphWorkspace/graphTheme.ts
+++ b/explorer/src/workspaces/GraphWorkspace/graphTheme.ts
@@ -266,16 +266,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",
@@ -286,12 +286,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",
@@ -311,7 +311,7 @@ export const GRAPH_THEME: GraphTheme = {
labelBudget: 4,
edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
- edgeSizeScale: 0.34,
+ edgeSizeScale: 0.62,
showBadges: false,
showCurves: false,
showContextualArrows: false,
@@ -334,8 +334,8 @@ export const GRAPH_THEME: GraphTheme = {
labelThreshold: 0.8,
labelBudget: 40,
edgePriorityThreshold: 0,
- arrowPriorityThreshold: 0.58,
- edgeSizeScale: 1.04,
+ arrowPriorityThreshold: 0.45,
+ edgeSizeScale: 1.18,
showBadges: true,
showCurves: true,
showContextualArrows: true,
@@ -438,14 +438,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 },
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx
rename to explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx
rename to explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/index.ts b/explorer/src/workspaces/GraphWorkspace/plugins/index.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/index.ts
rename to explorer/src/workspaces/GraphWorkspace/plugins/index.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx
rename to explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx
rename to explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx b/explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx
rename to explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts b/explorer/src/workspaces/GraphWorkspace/plugins/types.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/plugins/types.ts
rename to explorer/src/workspaces/GraphWorkspace/plugins/types.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/scene.ts b/explorer/src/workspaces/GraphWorkspace/scene.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/scene.ts
rename to explorer/src/workspaces/GraphWorkspace/scene.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts b/explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts
rename to explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/types.ts b/explorer/src/workspaces/GraphWorkspace/types.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/types.ts
rename to explorer/src/workspaces/GraphWorkspace/types.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/useGraphData.ts b/explorer/src/workspaces/GraphWorkspace/useGraphData.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/useGraphData.ts
rename to explorer/src/workspaces/GraphWorkspace/useGraphData.ts
diff --git a/semantica-explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts b/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts
rename to explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts
diff --git a/semantica-explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx b/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
similarity index 96%
rename from semantica-explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
rename to explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
index abf32aba..44b0df58 100644
--- a/semantica-explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
+++ b/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx
@@ -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 {
diff --git a/semantica-explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx b/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx
rename to explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx
diff --git a/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx b/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx
new file mode 100644
index 00000000..60866143
--- /dev/null
+++ b/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx
@@ -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;
+ edge_types?: Record;
+ [key: string]: unknown;
+}
+
+interface NodeItem {
+ id: string;
+ type: string;
+ content: string;
+ properties?: Record;
+}
+
+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 (
+
+
+ {label}
+
+
+
+ {count.toLocaleString()}
+ {pct}%
+
+
+ );
+}
+
+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 {
+ const map: Record = {};
+ 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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [topNodes, setTopNodes] = useState<{ node: NodeItem; neighborCount: number }[]>([]);
+ const [nodeTypeMap, setNodeTypeMap] = useState>({});
+
+ 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 = {};
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
KG Overview
+
Quick view of the Knowledge Graph structure and health
+
+
+
void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
+ {loading ? : }
+ Refresh
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {/* Stats chips */}
+
+ {[
+ { 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 }) => (
+
+
{label}
+
{loading ? "—" : value}
+
{sub}
+
+ ))}
+
+
+ {/* Type breakdowns */}
+
+ {/* Node types */}
+
+
Node Type Breakdown
+ {loading ? (
+
+ {[80, 65, 45, 35, 25].map((w, i) => (
+
+ ))}
+
+ ) : nodeTypeEntries.length === 0 ? (
+
No data — load the graph first.
+ ) : (
+ nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
+
+ ))
+ )}
+
+
+ {/* Edge types */}
+
+
Edge Type Breakdown
+ {loading ? (
+
+ {[70, 55, 48, 30, 20].map((w, i) => (
+
+ ))}
+
+ ) : edgeTypeEntries.length === 0 ? (
+
Edge type breakdown requires the stats endpoint to return edge_types.
+ ) : (
+ edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
+
+ ))
+ )}
+
+
+
+ {/* Top connected nodes */}
+ {topNodes.length > 0 ? (
+
+
Top Connected Nodes (by degree)
+
+ {topNodes.map(({ node, neighborCount }, rank) => (
+
+
#{rank + 1}
+
+
+ {node.content || node.id}
+
+
{node.type}
+
+
+ {neighborCount} conn.
+
+
+ ))}
+
+
+ ) : null}
+
+
+ );
+}
+
+/* ─── 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",
+};
diff --git a/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx b/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
new file mode 100644
index 00000000..4bb2e629
--- /dev/null
+++ b/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
@@ -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 (
+ <>
+ { (e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.07)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
+ >
+ {hasChildren ? (
+ setExpanded((v) => !v)}
+ style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", padding: 0, display: "flex", alignItems: "center" }}
+ >
+ {expanded ? : }
+
+ ) : (
+
+ )}
+ onSelect(concept)}
+ style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
+ >
+ {concept.pref_label || concept.uri}
+
+ {children.length > 0 ? (
+ {children.length}
+ ) : null}
+
+ {expanded && hasChildren
+ ? children.map((child) => (
+
+ ))
+ : 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 (
+
+ {/* Scheme header */}
+
setExpanded((v) => !v)}
+ style={schemeHeaderStyle}
+ >
+
+ {expanded ? : }
+ {scheme.label}
+
+
+ {isLoading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
+
+
+
+ {/* Concept tree */}
+ {expanded ? (
+
+ {isLoading ? (
+
Loading concepts…
+ ) : hierarchy.length === 0 ? (
+
+ No concepts found in this scheme.
+
+ ) : (
+ hierarchy.map((concept) => (
+
+ ))
+ )}
+
+ ) : null}
+
+ );
+}
+
+export function OntologySummaryTab({
+ onOpenVocabularyBrowser,
+}: {
+ onOpenVocabularyBrowser?: () => void;
+}) {
+ const { data: schemes = [], isLoading } = useVocabularies();
+ const [selectedConcept, setSelectedConcept] = useState(null);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Ontology Summary
+
+ {isLoading
+ ? "Loading schemes…"
+ : `${schemes.length} vocabulary scheme${schemes.length !== 1 ? "s" : ""} loaded`}
+
+
+
+ {onOpenVocabularyBrowser ? (
+
+
+ Open Full Browser
+
+ ) : null}
+
+
+
+ {/* Scheme tree column */}
+
+ {isLoading ? (
+
+ {[90, 75, 60].map((w, i) => (
+
+ ))}
+
+ ) : schemes.length === 0 ? (
+
+
+
No vocabulary schemes loaded
+
+ Import a .ttl or .rdf file via the Vocabulary Browser to see your ontology here.
+
+
+ ) : (
+
+ {schemes.map((scheme) => (
+
+ ))}
+
+ )}
+
+
+ {/* Concept detail panel */}
+ {selectedConcept ? (
+
+
+
+ Concept Detail
+
+
setSelectedConcept(null)} style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", fontSize: 16 }}>×
+
+
+
+ {selectedConcept.pref_label}
+
+ {selectedConcept.notation ? (
+
Notation: {selectedConcept.notation}
+ ) : null}
+
+ {selectedConcept.uri}
+
+
+ {selectedConcept.description ? (
+
+
Description
+
{selectedConcept.description}
+
+ ) : null}
+
+ {selectedConcept.alt_labels?.length ? (
+
+
Alternative Labels
+
+ {selectedConcept.alt_labels.map((label) => (
+ {label}
+ ))}
+
+
+ ) : null}
+
+ {(selectedConcept.children?.length ?? 0) > 0 ? (
+
+
Narrower Concepts ({selectedConcept.children!.length})
+
+ {selectedConcept.children!.slice(0, 8).map((child) => (
+
setSelectedConcept(child)}
+ style={{ color: "#79c0ff", fontSize: 12, cursor: "pointer", padding: "3px 0" }}
+ >
+ → {child.pref_label}
+
+ ))}
+ {selectedConcept.children!.length > 8 ? (
+
+{selectedConcept.children!.length - 8} more
+ ) : null}
+
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+/* ─── 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%",
+};
diff --git a/semantica-explorer/src/workspaces/ReasoningWorkspace.tsx b/explorer/src/workspaces/ReasoningWorkspace.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/ReasoningWorkspace.tsx
rename to explorer/src/workspaces/ReasoningWorkspace.tsx
diff --git a/semantica-explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx b/explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
rename to explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx b/explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx
rename to explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx b/explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx
rename to explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx b/explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx
rename to explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx b/explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx
rename to explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx b/explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx
rename to explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/queries.ts b/explorer/src/workspaces/VocabularyWorkspace/queries.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/queries.ts
rename to explorer/src/workspaces/VocabularyWorkspace/queries.ts
diff --git a/semantica-explorer/src/workspaces/VocabularyWorkspace/types.ts b/explorer/src/workspaces/VocabularyWorkspace/types.ts
similarity index 100%
rename from semantica-explorer/src/workspaces/VocabularyWorkspace/types.ts
rename to explorer/src/workspaces/VocabularyWorkspace/types.ts
diff --git a/semantica-explorer/tests/graphSceneState.display.test.ts b/explorer/tests/graphSceneState.display.test.ts
similarity index 100%
rename from semantica-explorer/tests/graphSceneState.display.test.ts
rename to explorer/tests/graphSceneState.display.test.ts
diff --git a/semantica-explorer/tests/graphStore.multi-edge.test.mjs b/explorer/tests/graphStore.multi-edge.test.mjs
similarity index 100%
rename from semantica-explorer/tests/graphStore.multi-edge.test.mjs
rename to explorer/tests/graphStore.multi-edge.test.mjs
diff --git a/semantica-explorer/tsconfig.app.json b/explorer/tsconfig.app.json
similarity index 100%
rename from semantica-explorer/tsconfig.app.json
rename to explorer/tsconfig.app.json
diff --git a/semantica-explorer/tsconfig.json b/explorer/tsconfig.json
similarity index 100%
rename from semantica-explorer/tsconfig.json
rename to explorer/tsconfig.json
diff --git a/semantica-explorer/tsconfig.node.json b/explorer/tsconfig.node.json
similarity index 100%
rename from semantica-explorer/tsconfig.node.json
rename to explorer/tsconfig.node.json
diff --git a/semantica-explorer/vite.config.ts b/explorer/vite.config.ts
similarity index 89%
rename from semantica-explorer/vite.config.ts
rename to explorer/vite.config.ts
index f741d247..2f87b55c 100644
--- a/semantica-explorer/vite.config.ts
+++ b/explorer/vite.config.ts
@@ -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: '/',
diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md
new file mode 100644
index 00000000..2ec29d90
--- /dev/null
+++ b/integrations/openclaw/README.md
@@ -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)
diff --git a/integrations/openclaw/__init__.py b/integrations/openclaw/__init__.py
new file mode 100644
index 00000000..b1e55431
--- /dev/null
+++ b/integrations/openclaw/__init__.py
@@ -0,0 +1,57 @@
+"""
+Semantica × OpenClaw Integration
+==================================
+
+First-class integration between the Semantica semantic intelligence stack and
+`OpenClaw `_ — 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"
diff --git a/integrations/openclaw/mcp_tool.py b/integrations/openclaw/mcp_tool.py
new file mode 100644
index 00000000..dff6b6db
--- /dev/null
+++ b/integrations/openclaw/mcp_tool.py
@@ -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})"
diff --git a/mcp/README.md b/mcp/README.md
new file mode 100644
index 00000000..bdbf25e1
--- /dev/null
+++ b/mcp/README.md
@@ -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
+```
diff --git a/mcp/__init__.py b/mcp/__init__.py
new file mode 100644
index 00000000..5867cde4
--- /dev/null
+++ b/mcp/__init__.py
@@ -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"
diff --git a/mcp/__main__.py b/mcp/__main__.py
new file mode 100644
index 00000000..f219b7d3
--- /dev/null
+++ b/mcp/__main__.py
@@ -0,0 +1,5 @@
+"""Entry point: python -m mcp.server"""
+from mcp.server import main
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp/resources/__init__.py b/mcp/resources/__init__.py
new file mode 100644
index 00000000..434c03d5
--- /dev/null
+++ b/mcp/resources/__init__.py
@@ -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"]
diff --git a/mcp/resources/registry.py b/mcp/resources/registry.py
new file mode 100644
index 00000000..052ee8c4
--- /dev/null
+++ b/mcp/resources/registry.py
@@ -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)}),
+ }
diff --git a/mcp/schemas.py b/mcp/schemas.py
new file mode 100644
index 00000000..5a9f8c7c
--- /dev/null
+++ b/mcp/schemas.py
@@ -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": {}}
diff --git a/mcp/server.py b/mcp/server.py
new file mode 100644
index 00000000..371343a5
--- /dev/null
+++ b/mcp/server.py
@@ -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()
diff --git a/mcp/session.py b/mcp/session.py
new file mode 100644
index 00000000..6569d1ad
--- /dev/null
+++ b/mcp/session.py
@@ -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
diff --git a/mcp/tools/__init__.py b/mcp/tools/__init__.py
new file mode 100644
index 00000000..333cb133
--- /dev/null
+++ b/mcp/tools/__init__.py
@@ -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"]
diff --git a/mcp/tools/decisions.py b/mcp/tools/decisions.py
new file mode 100644
index 00000000..dc894efc
--- /dev/null
+++ b/mcp/tools/decisions.py
@@ -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,
+ },
+]
diff --git a/mcp/tools/export.py b/mcp/tools/export.py
new file mode 100644
index 00000000..f435bf18
--- /dev/null
+++ b/mcp/tools/export.py
@@ -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,
+ },
+]
diff --git a/mcp/tools/extraction.py b/mcp/tools/extraction.py
new file mode 100644
index 00000000..7ccd385b
--- /dev/null
+++ b/mcp/tools/extraction.py
@@ -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,
+ },
+]
diff --git a/mcp/tools/graph.py b/mcp/tools/graph.py
new file mode 100644
index 00000000..5508025b
--- /dev/null
+++ b/mcp/tools/graph.py
@@ -0,0 +1,187 @@
+"""
+Graph tools — add entities/relationships, search, analytics, summary.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from mcp.schemas import ADD_ENTITY, ADD_RELATIONSHIP, EMPTY, GET_ANALYTICS, SEARCH_GRAPH
+from mcp.session import get_graph
+
+log = logging.getLogger("semantica.mcp.tools.graph")
+
+
+def handle_add_entity(args: dict) -> dict:
+ """Add a node/entity to the Semantica knowledge graph."""
+ node_id = args.get("id", "").strip()
+ if not node_id:
+ return {"error": "id is required"}
+ try:
+ graph = get_graph()
+ graph.add_node(
+ node_id=node_id,
+ label=args.get("label", node_id),
+ node_type=args.get("type", "Entity"),
+ metadata=args.get("metadata", {}),
+ )
+ return {"status": "added", "id": node_id, "type": args.get("type", "Entity")}
+ except Exception as exc:
+ log.exception("add_entity failed")
+ return {"error": str(exc)}
+
+
+def handle_add_relationship(args: dict) -> dict:
+ """Add a directed relationship (edge) between two entities."""
+ source = args.get("source", "").strip()
+ target = args.get("target", "").strip()
+ if not source or not target:
+ return {"error": "source and target are required"}
+ rel_type = args.get("type", "RELATED_TO")
+ try:
+ graph = get_graph()
+ graph.add_edge(
+ source_id=source,
+ target_id=target,
+ edge_type=rel_type,
+ metadata=args.get("metadata", {}),
+ )
+ return {"status": "added", "source": source, "target": target, "type": rel_type}
+ except Exception as exc:
+ log.exception("add_relationship failed")
+ return {"error": str(exc)}
+
+
+def handle_search_graph(args: dict) -> dict:
+ """Search nodes in the knowledge graph by label or metadata."""
+ query = args.get("query", "").strip()
+ if not query:
+ return {"error": "query is required", "results": []}
+ node_type = args.get("node_type", "").strip() or None
+ limit = int(args.get("limit", 20))
+ try:
+ graph = get_graph()
+ if node_type:
+ nodes = list(graph.find_nodes(node_type=node_type))
+ else:
+ nodes = list(graph.find_nodes())
+ q = query.lower()
+ matched = [
+ n for n in nodes
+ if q in str(n.get("label", "")).lower()
+ or q in str(n.get("id", "")).lower()
+ ][:limit]
+ return {"results": matched, "count": len(matched), "query": query}
+ except Exception as exc:
+ log.exception("search_graph failed")
+ return {"error": str(exc), "results": []}
+
+
+def handle_get_graph_summary(args: dict) -> dict: # noqa: ARG001
+ """Return a high-level summary of the current knowledge graph."""
+ try:
+ graph = get_graph()
+ all_nodes = list(graph.find_nodes())
+ decisions = [n for n in all_nodes if n.get("type") in ("decision", "Decision")]
+ node_types: dict[str, int] = {}
+ for n in all_nodes:
+ t = str(n.get("type", "Unknown"))
+ node_types[t] = node_types.get(t, 0) + 1
+ edge_count = 0
+ if hasattr(graph, "edge_count"):
+ try:
+ edge_count = graph.edge_count()
+ except Exception:
+ log.exception("graph.edge_count failed; defaulting edge_count to 0")
+ return {
+ "node_count": len(all_nodes),
+ "edge_count": edge_count,
+ "decision_count": len(decisions),
+ "node_types": node_types,
+ "graph_ready": True,
+ }
+ except Exception as exc:
+ log.exception("get_graph_summary failed")
+ return {"error": str(exc), "graph_ready": False}
+
+
+def handle_get_graph_analytics(args: dict) -> dict:
+ """Compute centrality, community detection, and other graph metrics."""
+ requested = args.get("metrics", ["all"])
+ top_n = int(args.get("top_n", 10))
+ compute_all = "all" in requested
+ result: dict = {}
+ try:
+ graph = get_graph()
+ from semantica.kg import CentralityCalculator, CommunityDetector
+
+ if compute_all or "pagerank" in requested:
+ try:
+ pr = CentralityCalculator().calculate_pagerank(graph)
+ items = pr.items() if hasattr(pr, "items") else []
+ result["pagerank"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
+ except Exception as exc:
+ result["pagerank_error"] = str(exc)
+
+ if compute_all or "betweenness" in requested:
+ try:
+ bc = CentralityCalculator().calculate_betweenness_centrality(graph)
+ items = bc.items() if hasattr(bc, "items") else []
+ result["betweenness"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
+ except Exception as exc:
+ result["betweenness_error"] = str(exc)
+
+ if compute_all or "communities" in requested:
+ try:
+ comms = CommunityDetector().detect_communities(graph)
+ result["community_count"] = len(comms) if isinstance(comms, (list, dict)) else 0
+ result["communities"] = comms if isinstance(comms, list) else []
+ except Exception as exc:
+ result["communities_error"] = str(exc)
+
+ if compute_all or "degree" in requested:
+ try:
+ deg = CentralityCalculator().calculate_degree_centrality(graph)
+ items = deg.items() if hasattr(deg, "items") else []
+ result["degree"] = sorted(items, key=lambda x: x[1], reverse=True)[:top_n]
+ except Exception as exc:
+ result["degree_error"] = str(exc)
+
+ return result
+ except Exception as exc:
+ log.exception("get_graph_analytics failed")
+ return {"error": str(exc)}
+
+
+GRAPH_TOOLS = [
+ {
+ "name": "add_entity",
+ "description": "Add a node or entity (person, place, concept, organisation) to the knowledge graph.",
+ "inputSchema": ADD_ENTITY,
+ "_handler": handle_add_entity,
+ },
+ {
+ "name": "add_relationship",
+ "description": "Add a directed relationship (edge) between two entities in the knowledge graph.",
+ "inputSchema": ADD_RELATIONSHIP,
+ "_handler": handle_add_relationship,
+ },
+ {
+ "name": "search_graph",
+ "description": "Search nodes in the knowledge graph by label or ID substring.",
+ "inputSchema": SEARCH_GRAPH,
+ "_handler": handle_search_graph,
+ },
+ {
+ "name": "get_graph_summary",
+ "description": "Return a high-level summary of the knowledge graph: node count, edge count, decision count, node type breakdown.",
+ "inputSchema": EMPTY,
+ "_handler": handle_get_graph_summary,
+ },
+ {
+ "name": "get_graph_analytics",
+ "description": "Compute PageRank centrality, betweenness centrality, degree centrality, and community detection over the knowledge graph.",
+ "inputSchema": GET_ANALYTICS,
+ "_handler": handle_get_graph_analytics,
+ },
+]
diff --git a/mcp/tools/reasoning.py b/mcp/tools/reasoning.py
new file mode 100644
index 00000000..98e888eb
--- /dev/null
+++ b/mcp/tools/reasoning.py
@@ -0,0 +1,73 @@
+"""
+Reasoning tools — forward chaining, abductive reasoning.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from mcp.schemas import ABDUCTIVE_REASONING, RUN_REASONING
+
+log = logging.getLogger("semantica.mcp.tools.reasoning")
+
+
+def handle_run_reasoning(args: dict) -> dict:
+ """Run forward-chaining IF/THEN rules over facts to derive new knowledge."""
+ facts = args.get("facts", [])
+ rules = args.get("rules", [])
+ if not facts:
+ return {"error": "facts list is required", "derived_facts": []}
+ if not rules:
+ return {"error": "rules list is required", "derived_facts": []}
+ try:
+ from semantica.reasoning import Reasoner
+ reasoner = Reasoner()
+ for rule in rules:
+ reasoner.add_rule(str(rule))
+ derived = reasoner.infer_facts(facts)
+ result = derived if isinstance(derived, list) else list(derived)
+ return {
+ "derived_facts": result,
+ "count": len(result),
+ "input_facts": len(facts),
+ "rules_applied": len(rules),
+ }
+ except Exception as exc:
+ log.exception("run_reasoning failed")
+ return {"error": str(exc), "derived_facts": []}
+
+
+def handle_abductive_reasoning(args: dict) -> dict:
+ """Generate plausible hypotheses that explain a set of observations."""
+ observations = args.get("observations", [])
+ if not observations:
+ return {"error": "observations list is required", "hypotheses": []}
+ max_hypotheses = int(args.get("max_hypotheses", 5))
+ try:
+ from semantica.reasoning import AbductiveReasoner
+ reasoner = AbductiveReasoner()
+ hypotheses = reasoner.generate_hypotheses(observations)
+ result = hypotheses if isinstance(hypotheses, list) else list(hypotheses)
+ return {
+ "hypotheses": result[:max_hypotheses],
+ "count": min(len(result), max_hypotheses),
+ }
+ except Exception as exc:
+ log.exception("abductive_reasoning failed")
+ return {"error": str(exc), "hypotheses": []}
+
+
+REASONING_TOOLS = [
+ {
+ "name": "run_reasoning",
+ "description": "Run forward-chaining IF/THEN rules over a set of facts to derive new facts. E.g. facts=['Person(John)'], rules=['IF Person(?x) THEN Mortal(?x)'] → derives 'Mortal(John)'.",
+ "inputSchema": RUN_REASONING,
+ "_handler": handle_run_reasoning,
+ },
+ {
+ "name": "abductive_reasoning",
+ "description": "Generate plausible hypotheses that best explain a set of observed facts.",
+ "inputSchema": ABDUCTIVE_REASONING,
+ "_handler": handle_abductive_reasoning,
+ },
+]
diff --git a/plugins/.claude-plugin/README.md b/plugins/.claude-plugin/README.md
index 8aca646c..bdf90fcb 100644
--- a/plugins/.claude-plugin/README.md
+++ b/plugins/.claude-plugin/README.md
@@ -1,135 +1,259 @@
# Semantica Plugins (Community Guide)
-Semantica ships a shared plugin bundle under `plugins/` with skills, agents, and hooks for knowledge graphs, context graphs, decision intelligence, reasoning, explainability, provenance, ontology, and export workflows.
+> **v0.4.0** — 17 domain skills · 3 agents · 8 platform plugins · Knowledge Explorer UI
-This README is for community users who want to install or reuse the plugin package across Claude, Cursor, and Codex.
+Semantica ships a shared plugin bundle under `plugins/` that works across every major AI coding assistant. Connect any supported platform to Semantica's knowledge graph engine for semantic extraction, decision intelligence, reasoning, provenance, ontology, and export workflows.
-## Supported Platforms
+---
-- Claude Code
-- Cursor
-- Codex
+## Platform Plugins
+
+Semantica provides a dedicated plugin for each platform. Every plugin shares the same `skills/`, `agents/`, and `hooks/` bundle — only the manifest format differs.
+
+| # | Platform | Plugin Folder | Setup |
+|---|----------|--------------|-------|
+| 1 | **Claude Code** | `.claude-plugin/` | `claude --plugin-dir ./plugins` |
+| 2 | **Cursor** | `.cursor-plugin/` | Cursor Marketplace → refresh |
+| 3 | **Codex** | `.codex-plugin/` | Marketplace UI → install |
+| 4 | **Cline** | `.cline-plugin/` | Cline MCP settings |
+| 5 | **Windsurf** | `.windsurf-plugin/` | `mcp_config.json` |
+| 6 | **Continue** | `.continue-plugin/` | `~/.continue/config.json` |
+| 7 | **OpenClaw** | `.openclaw-plugin/` | `mcporter.json` |
+| 8 | **VS Code** | `.vscode-plugin/` | `settings.json` MCP entry |
+
+---
+
+## What's Included
+
+```
+plugins/
+├── skills/ # 17 domain skills (slash commands)
+├── agents/ # 3 specialized agents
+├── hooks/ # hooks.json
+├── .claude-plugin/ # Claude Code manifest + marketplace
+├── .cursor-plugin/ # Cursor manifest + marketplace
+├── .codex-plugin/ # Codex manifest + marketplace
+├── .cline-plugin/ # Cline manifest + marketplace
+├── .windsurf-plugin/ # Windsurf manifest + marketplace
+├── .continue-plugin/ # Continue manifest + marketplace
+├── .openclaw-plugin/ # OpenClaw manifest + marketplace
+└── .vscode-plugin/ # VS Code manifest + marketplace
+```
+
+### Skills (17)
+
+`extract` · `ingest` · `query` · `ontology` · `validate` · `deduplicate` · `embed` · `reason` · `decision` · `causal` · `temporal` · `provenance` · `policy` · `explain` · `export` · `change` · `visualize`
+
+### Agents (3)
+
+`decision-advisor` · `explainability` · `kg-assistant`
+
+---
## Prerequisites
-1. Clone the repository:
-
```bash
git clone https://github.com/Hawksight-AI/semantica.git
cd semantica
+pip install semantica # Python 3.10+
```
-2. Ensure the plugin bundle exists at:
+---
-```text
-plugins/
- skills/
- agents/
- hooks/
- .claude-plugin/
- .cursor-plugin/
- .codex-plugin/
+## Knowledge Explorer (v0.4.0)
+
+Launch the interactive graph dashboard:
+
+```bash
+semantica-explorer --graph my_graph.json --port 8000
```
-## Plugin Contents
+Open **http://localhost:5174** to explore:
-- `skills/`: 17 domain skills (`causal`, `decision`, `explain`, `reason`, `temporal`, etc.)
-- `agents/`: specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
-- `hooks/hooks.json`: plugin hook configuration
-- `.claude-plugin/plugin.json`: Claude manifest
-- `.cursor-plugin/plugin.json`: Cursor manifest
-- `.codex-plugin/plugin.json`: Codex manifest
-- `*/marketplace.json`: local marketplace definitions
+- **Graph** — interactive canvas with ForceAtlas2 layout, path highlight, community coloring
+- **Decisions** — causal chains and outcome analysis
+- **Reasoning** — run deductive / abductive rules
+- **SPARQL** — Monaco editor for graph queries
+- **Vocabulary** — ontology concept tree
+- **Lineage** — provenance lineage diagram
+- **Import / Export** — JSON, RDF, Parquet, GraphML
-## Install and Use in Claude Code
+---
-### Local install (fastest)
+## Installation by Platform
-From the repository root:
+### Claude Code
```bash
claude --plugin-dir ./plugins
```
-If your Claude setup uses plugin commands in-session, use:
+Or inside a session:
```bash
/plugin install ./plugins
```
-### Install from a GitHub marketplace
+Verify:
-Add a marketplace hosted in git:
-
-```bash
-/plugin marketplace add /semantica
```
-
-Install Semantica from that marketplace:
-
-```bash
-/plugin install semantica@
-```
-
-### Verify in Claude
-
-Run one of these in chat:
-
-```text
/semantica:decision list
/semantica:explain decision
```
-If the plugin is installed correctly, Claude should recognize the `/semantica:*` skills.
+---
-## Install and Use in Codex
+### Cursor
+
+Cursor reads `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace.json` automatically. Publish the `plugins/` directory and refresh in Cursor Marketplace to pick up updates.
+
+Verify:
+
+```
+/semantica:visualize topology
+/semantica:reason deductive "IF Person(x) THEN Mortal(x)"
+```
+
+---
+
+### Codex
1. Ensure your repo marketplace exists at `.agents/plugins/marketplace.json`.
-2. Point the plugin entry `source.path` to `./plugins` (or your chosen plugin directory).
+2. Set `source.path` to `./plugins` in the plugin entry.
3. Restart Codex and install from the marketplace UI.
-Codex manifest used by this bundle:
+Verify:
-- `.codex-plugin/plugin.json`
-
-### Verify in Codex
-
-After install, run a Semantica skill command in chat, for example:
-
-```text
+```
/semantica:causal chain --subject --depth 3
```
-## Install and Use in Cursor
+---
-Cursor reads plugin metadata from:
+### Cline
-- `.cursor-plugin/plugin.json`
-- `.cursor-plugin/marketplace.json`
+In Cline MCP settings, add:
-If you maintain a team/community plugin repo, publish this `plugins/` directory and refresh/reinstall in Cursor Marketplace to pick up updates.
-
-### Verify in Cursor
-
-Try one of these commands:
-
-```text
-/semantica:reason deductive "IF Person(x) THEN Mortal(x)"
-/semantica:visualize topology
+```json
+{
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"],
+ "env": {}
+ }
+}
```
+---
+
+### Windsurf
+
+Add to `~/.codeium/windsurf/mcp_config.json`:
+
+```json
+{
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+---
+
+### Continue
+
+Add to `~/.continue/config.json`:
+
+```json
+{
+ "mcpServers": [
+ {
+ "name": "semantica",
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ ]
+}
+```
+
+All 17 Semantica skills appear in the `@semantica` context provider dropdown.
+
+---
+
+### OpenClaw
+
+Add to `~/.openclaw/mcporter.json`:
+
+```json
+{
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"],
+ "transport": "stdio"
+ }
+ }
+}
+```
+
+Then restart the gateway:
+
+```bash
+openclaw gateway restart
+```
+
+---
+
+### VS Code
+
+Add to `settings.json` (GitHub Copilot Chat):
+
+```json
+{
+ "github.copilot.chat.mcp.servers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+Or for the VS Code MCP extension:
+
+```json
+{
+ "mcp.servers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+---
+
## First Commands to Try
-After installing on any platform, these are good smoke tests:
+After installing on any platform:
-1. `/semantica:decision record "" "" `
-2. `/semantica:decision list`
-3. `/semantica:causal chain --subject --depth 3`
-4. `/semantica:explain decision `
-5. `/semantica:validate graph`
+```
+/semantica:decision record "" ""
+/semantica:decision list
+/semantica:causal chain --subject --depth 3
+/semantica:explain decision
+/semantica:validate graph
+/semantica:visualize topology
+```
+
+---
## Community Notes
-- Keep plugin name/version/keywords updated in each manifest before publishing.
-- Keep skill frontmatter consistent (`name` + `description`) for reliable discovery.
-- For open-source sharing, include this folder as-is so skills, agents, and hooks remain bundled.
+- Keep `name` / `version` / `keywords` updated in each manifest before publishing.
+- Keep skill frontmatter (`name` + `description`) consistent for reliable discovery.
+- Include `plugins/` as-is when sharing — skills, agents, and hooks must stay bundled.
diff --git a/plugins/.cline-plugin/README.md b/plugins/.cline-plugin/README.md
new file mode 100644
index 00000000..5d7a17ea
--- /dev/null
+++ b/plugins/.cline-plugin/README.md
@@ -0,0 +1,32 @@
+# Semantica — Cline Plugin
+
+> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
+
+## MCP Server Setup (recommended)
+
+In Cline settings, add a new MCP server:
+
+```json
+{
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"],
+ "env": {}
+ }
+}
+```
+
+Cline will discover all 17 Semantica skills and 3 agents automatically on connection.
+
+## Knowledge Explorer
+
+```bash
+semantica-explorer --graph my_graph.json --port 8000
+```
+
+Open `http://localhost:5174` for the interactive dashboard.
+
+## Requirements
+
+- Python 3.10+
+- `pip install semantica`
diff --git a/plugins/.cline-plugin/marketplace.json b/plugins/.cline-plugin/marketplace.json
new file mode 100644
index 00000000..335fb8fc
--- /dev/null
+++ b/plugins/.cline-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "semantica-cline",
+ "plugins": [
+ {
+ "name": "semantica",
+ "description": "Semantica plugin for Cline: knowledge graph skills, reasoning, extraction, and visualization.",
+ "source": "./",
+ "category": "Productivity",
+ "tags": [
+ "knowledge-graph",
+ "reasoning",
+ "semantica",
+ "cline"
+ ]
+ }
+ ]
+}
diff --git a/plugins/.cline-plugin/plugin.json b/plugins/.cline-plugin/plugin.json
new file mode 100644
index 00000000..81c004c4
--- /dev/null
+++ b/plugins/.cline-plugin/plugin.json
@@ -0,0 +1,35 @@
+{
+ "name": "semantica-cline",
+ "displayName": "Semantica Cline Plugin",
+ "description": "Semantica plugin for Cline: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization.",
+ "version": "0.1.0",
+ "author": {
+ "name": "Semantica Contributors"
+ },
+ "homepage": "https://github.com/Hawksight-AI/semantica",
+ "repository": "https://github.com/Hawksight-AI/semantica",
+ "license": "MIT",
+ "keywords": [
+ "semantica",
+ "knowledge graph",
+ "cline",
+ "context graphs",
+ "decision intelligence",
+ "explainability",
+ "causal analysis",
+ "provenance",
+ "ontology",
+ "graph analytics",
+ "semantic extraction",
+ "visualization",
+ "reasoning",
+ "mcp"
+ ],
+ "skills": "../skills",
+ "agents": "../agents",
+ "hooks": "../hooks/hooks.json",
+ "mcp": {
+ "server": "python -m semantica.mcp_server",
+ "transport": "stdio"
+ }
+}
diff --git a/plugins/.continue-plugin/README.md b/plugins/.continue-plugin/README.md
new file mode 100644
index 00000000..ed288d0a
--- /dev/null
+++ b/plugins/.continue-plugin/README.md
@@ -0,0 +1,34 @@
+# Semantica — Continue Plugin
+
+> **v0.4.0** — Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
+
+## MCP Server Setup
+
+Add to `~/.continue/config.json`:
+
+```json
+{
+ "mcpServers": [
+ {
+ "name": "semantica",
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ ]
+}
+```
+
+Continue will show all 17 Semantica skills in the `@semantica` context provider dropdown.
+
+## Knowledge Explorer
+
+```bash
+semantica-explorer --graph my_graph.json --port 8000
+```
+
+Open `http://localhost:5174` for the interactive graph dashboard.
+
+## Requirements
+
+- Python 3.10+
+- `pip install semantica`
diff --git a/plugins/.continue-plugin/marketplace.json b/plugins/.continue-plugin/marketplace.json
new file mode 100644
index 00000000..0ff7e099
--- /dev/null
+++ b/plugins/.continue-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "semantica-continue",
+ "plugins": [
+ {
+ "name": "semantica",
+ "description": "Semantica plugin for Continue.dev: knowledge graph context provider, reasoning, and extraction.",
+ "source": "./",
+ "category": "Productivity",
+ "tags": [
+ "knowledge-graph",
+ "reasoning",
+ "semantica",
+ "continue"
+ ]
+ }
+ ]
+}
diff --git a/plugins/.continue-plugin/plugin.json b/plugins/.continue-plugin/plugin.json
new file mode 100644
index 00000000..da93dc38
--- /dev/null
+++ b/plugins/.continue-plugin/plugin.json
@@ -0,0 +1,33 @@
+{
+ "name": "semantica-continue",
+ "displayName": "Semantica Continue Plugin",
+ "description": "Semantica plugin for Continue.dev: knowledge graph context provider, decision intelligence, reasoning, and semantic extraction.",
+ "version": "0.1.0",
+ "author": {
+ "name": "Semantica Contributors"
+ },
+ "homepage": "https://github.com/Hawksight-AI/semantica",
+ "repository": "https://github.com/Hawksight-AI/semantica",
+ "license": "MIT",
+ "keywords": [
+ "semantica",
+ "knowledge graph",
+ "continue",
+ "context provider",
+ "decision intelligence",
+ "explainability",
+ "causal analysis",
+ "provenance",
+ "ontology",
+ "semantic extraction",
+ "reasoning",
+ "mcp"
+ ],
+ "skills": "../skills",
+ "agents": "../agents",
+ "hooks": "../hooks/hooks.json",
+ "mcp": {
+ "server": "python -m semantica.mcp_server",
+ "transport": "stdio"
+ }
+}
diff --git a/plugins/.openclaw-plugin/README.md b/plugins/.openclaw-plugin/README.md
new file mode 100644
index 00000000..a43d2cd3
--- /dev/null
+++ b/plugins/.openclaw-plugin/README.md
@@ -0,0 +1,62 @@
+# Semantica — OpenClaw Plugin
+
+> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and the full MCP integration to [OpenClaw](https://openclaw.ai) — the open-source personal AI agent platform.
+
+## MCP Server Setup (recommended)
+
+### 1. Start the Semantica MCP server
+
+```bash
+python -m semantica.mcp_server
+```
+
+### 2. Add to `mcporter.json`
+
+Paste the following into your OpenClaw `mcporter.json` (usually `~/.openclaw/mcporter.json`):
+
+```json
+{
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"],
+ "transport": "stdio"
+ }
+ }
+}
+```
+
+### 3. Restart the OpenClaw Gateway
+
+```bash
+openclaw gateway restart
+```
+
+OpenClaw will automatically discover all 17 Semantica tools and 3 agents.
+
+## Skills
+
+All 17 skills under [`plugins/skills/`](../skills/) are available once the plugin is loaded:
+
+`extract` · `ingest` · `query` · `ontology` · `validate` · `deduplicate` · `embed` · `reason` · `decision` · `causal` · `temporal` · `provenance` · `policy` · `explain` · `export` · `change` · `visualize`
+
+## Native Tool (REST, no MCP gateway)
+
+For agents that cannot use the MCP gateway, use the `OpenClawKGTool` REST wrapper:
+
+```python
+from integrations.openclaw import OpenClawKGTool
+
+tool = OpenClawKGTool(base_url="http://localhost:8000")
+entities = tool.extract_entities("Alice manages the project.")
+tool.record_decision("Deploy model v2 to production")
+summary = tool.get_graph_summary()
+```
+
+See [`integrations/openclaw/README.md`](../../integrations/openclaw/README.md) for the full guide, including SOUL.md agent snippets.
+
+## Requirements
+
+- Python 3.10+
+- `pip install semantica`
+- OpenClaw — [openclaw.ai](https://openclaw.ai)
diff --git a/plugins/.openclaw-plugin/marketplace.json b/plugins/.openclaw-plugin/marketplace.json
new file mode 100644
index 00000000..93f5060b
--- /dev/null
+++ b/plugins/.openclaw-plugin/marketplace.json
@@ -0,0 +1,18 @@
+{
+ "name": "semantica-openclaw",
+ "plugins": [
+ {
+ "name": "semantica",
+ "description": "Semantica plugin for OpenClaw: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization.",
+ "source": "./",
+ "category": "Productivity",
+ "tags": [
+ "knowledge-graph",
+ "reasoning",
+ "semantica",
+ "openclaw",
+ "mcp"
+ ]
+ }
+ ]
+}
diff --git a/plugins/.openclaw-plugin/plugin.json b/plugins/.openclaw-plugin/plugin.json
new file mode 100644
index 00000000..0489b609
--- /dev/null
+++ b/plugins/.openclaw-plugin/plugin.json
@@ -0,0 +1,46 @@
+{
+ "name": "semantica-openclaw",
+ "displayName": "Semantica OpenClaw Plugin",
+ "description": "Semantica plugin for OpenClaw: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization via MCP and native REST tool.",
+ "version": "0.1.0",
+ "author": {
+ "name": "Semantica Contributors"
+ },
+ "homepage": "https://github.com/Hawksight-AI/semantica",
+ "repository": "https://github.com/Hawksight-AI/semantica",
+ "license": "MIT",
+ "keywords": [
+ "semantica",
+ "knowledge graph",
+ "openclaw",
+ "context graphs",
+ "decision intelligence",
+ "explainability",
+ "causal analysis",
+ "provenance",
+ "ontology",
+ "graph analytics",
+ "semantic extraction",
+ "visualization",
+ "reasoning",
+ "mcp"
+ ],
+ "skills": "../skills",
+ "agents": "../agents",
+ "hooks": "../hooks/hooks.json",
+ "mcp": {
+ "server": "python -m semantica.mcp_server",
+ "transport": "stdio"
+ },
+ "openclaw": {
+ "mcporter": {
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"],
+ "transport": "stdio"
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/.vscode-plugin/README.md b/plugins/.vscode-plugin/README.md
new file mode 100644
index 00000000..56526018
--- /dev/null
+++ b/plugins/.vscode-plugin/README.md
@@ -0,0 +1,48 @@
+# Semantica — VS Code Plugin
+
+> **v0.4.0** — Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
+
+## MCP Server Setup
+
+Add to your VS Code `settings.json`:
+
+```json
+{
+ "github.copilot.chat.mcp.servers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+Or if using the VS Code MCP extension directly:
+
+```json
+{
+ "mcp.servers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+VS Code will discover all 17 Semantica skills and 3 agents automatically on connection.
+
+## Knowledge Explorer
+
+Launch the interactive graph dashboard from the terminal:
+
+```bash
+semantica-explorer --graph my_graph.json --port 8000
+```
+
+Open `http://localhost:5174` to explore nodes, edges, decisions, SPARQL, lineage, and more.
+
+## Requirements
+
+- Python 3.10+
+- `pip install semantica`
diff --git a/plugins/.vscode-plugin/marketplace.json b/plugins/.vscode-plugin/marketplace.json
new file mode 100644
index 00000000..76f4b2a2
--- /dev/null
+++ b/plugins/.vscode-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "semantica-vscode",
+ "plugins": [
+ {
+ "name": "semantica",
+ "description": "Semantica plugin for VS Code: knowledge graph skills, reasoning, extraction, and visualization.",
+ "source": "./",
+ "category": "Productivity",
+ "tags": [
+ "knowledge-graph",
+ "reasoning",
+ "semantica",
+ "vscode"
+ ]
+ }
+ ]
+}
diff --git a/plugins/.vscode-plugin/plugin.json b/plugins/.vscode-plugin/plugin.json
new file mode 100644
index 00000000..a39c097e
--- /dev/null
+++ b/plugins/.vscode-plugin/plugin.json
@@ -0,0 +1,35 @@
+{
+ "name": "semantica-vscode",
+ "displayName": "Semantica VS Code Plugin",
+ "description": "Semantica plugin for VS Code: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization via MCP server.",
+ "version": "0.1.0",
+ "author": {
+ "name": "Semantica Contributors"
+ },
+ "homepage": "https://github.com/Hawksight-AI/semantica",
+ "repository": "https://github.com/Hawksight-AI/semantica",
+ "license": "MIT",
+ "keywords": [
+ "semantica",
+ "knowledge graph",
+ "vscode",
+ "context graphs",
+ "decision intelligence",
+ "explainability",
+ "causal analysis",
+ "provenance",
+ "ontology",
+ "graph analytics",
+ "semantic extraction",
+ "visualization",
+ "reasoning",
+ "mcp"
+ ],
+ "skills": "../skills",
+ "agents": "../agents",
+ "hooks": "../hooks/hooks.json",
+ "mcp": {
+ "server": "python -m semantica.mcp_server",
+ "transport": "stdio"
+ }
+}
diff --git a/plugins/.windsurf-plugin/README.md b/plugins/.windsurf-plugin/README.md
new file mode 100644
index 00000000..2dff4ea9
--- /dev/null
+++ b/plugins/.windsurf-plugin/README.md
@@ -0,0 +1,39 @@
+# Semantica — Windsurf Plugin
+
+> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
+
+## MCP Server Setup (recommended)
+
+Add to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`):
+
+```json
+{
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+}
+```
+
+Windsurf will have access to all 17 Semantica skills (`extract`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, and more) directly in the AI panel.
+
+## Skills
+
+All 17 skills under `plugins/skills/` are available as slash commands once the plugin is loaded:
+
+`extract` · `ingest` · `query` · `ontology` · `validate` · `deduplicate` · `embed` · `reason` · `decision` · `causal` · `temporal` · `provenance` · `policy` · `explain` · `export` · `change` · `visualize`
+
+## Knowledge Explorer
+
+```bash
+semantica-explorer --graph my_graph.json --port 8000
+```
+
+Open `http://localhost:5174` for the interactive graph dashboard.
+
+## Requirements
+
+- Python 3.10+
+- `pip install semantica`
diff --git a/plugins/.windsurf-plugin/marketplace.json b/plugins/.windsurf-plugin/marketplace.json
new file mode 100644
index 00000000..e5ea82db
--- /dev/null
+++ b/plugins/.windsurf-plugin/marketplace.json
@@ -0,0 +1,17 @@
+{
+ "name": "semantica-windsurf",
+ "plugins": [
+ {
+ "name": "semantica",
+ "description": "Semantica plugin for Windsurf: knowledge graph skills, reasoning, extraction, and visualization.",
+ "source": "./",
+ "category": "Productivity",
+ "tags": [
+ "knowledge-graph",
+ "reasoning",
+ "semantica",
+ "windsurf"
+ ]
+ }
+ ]
+}
diff --git a/plugins/.windsurf-plugin/plugin.json b/plugins/.windsurf-plugin/plugin.json
new file mode 100644
index 00000000..cbf45713
--- /dev/null
+++ b/plugins/.windsurf-plugin/plugin.json
@@ -0,0 +1,35 @@
+{
+ "name": "semantica-windsurf",
+ "displayName": "Semantica Windsurf Plugin",
+ "description": "Semantica plugin for Windsurf: knowledge graph skills, decision intelligence, reasoning, extraction, and visualization.",
+ "version": "0.1.0",
+ "author": {
+ "name": "Semantica Contributors"
+ },
+ "homepage": "https://github.com/Hawksight-AI/semantica",
+ "repository": "https://github.com/Hawksight-AI/semantica",
+ "license": "MIT",
+ "keywords": [
+ "semantica",
+ "knowledge graph",
+ "windsurf",
+ "context graphs",
+ "decision intelligence",
+ "explainability",
+ "causal analysis",
+ "provenance",
+ "ontology",
+ "graph analytics",
+ "semantic extraction",
+ "visualization",
+ "reasoning",
+ "mcp"
+ ],
+ "skills": "../skills",
+ "agents": "../agents",
+ "hooks": "../hooks/hooks.json",
+ "mcp": {
+ "server": "python -m semantica.mcp_server",
+ "transport": "stdio"
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index 98a8f1ad..dd49f21c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.18.0"]
llm-ollama = ["ollama>=0.1.0"]
-llm-deepseek = ["deepseek>=0.1.0"]
+llm-deepseek = ["openai>=1.0.0"]
llm-litellm = ["litellm>=1.0.0"]
llm-instructor = ["instructor>=1.0.0"]
diff --git a/semantica-explorer/README.md b/semantica-explorer/README.md
deleted file mode 100644
index c3c21da2..00000000
--- a/semantica-explorer/README.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# React + TypeScript + Vite
-
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
-
-Currently, two official plugins are available:
-
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
-
-## React Compiler
-
-The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
-
-Note: This will impact Vite dev & build performances.
-
-## Expanding the ESLint configuration
-
-If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
-
-```js
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
-
- // Remove tseslint.configs.recommended and replace with this
- tseslint.configs.recommendedTypeChecked,
- // Alternatively, use this for stricter rules
- tseslint.configs.strictTypeChecked,
- // Optionally, add this for stylistic rules
- tseslint.configs.stylisticTypeChecked,
-
- // Other configs...
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
-```
-
-You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
-
-```js
-// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
-
-export default defineConfig([
- globalIgnores(['dist']),
- {
- files: ['**/*.{ts,tsx}'],
- extends: [
- // Other configs...
- // Enable lint rules for React
- reactX.configs['recommended-typescript'],
- // Enable lint rules for React DOM
- reactDom.configs.recommended,
- ],
- languageOptions: {
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- // other options...
- },
- },
-])
-```
diff --git a/semantica-explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx b/semantica-explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
deleted file mode 100644
index 07b38f82..00000000
--- a/semantica-explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
- */
-import { useState, useEffect } from "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);
- }
-`;
-
-
-
-function CausalChainNode({ hop, title, desc }: { hop: number, title: string, desc: string }) {
- return (
-
- );
-}
-
-export function DecisionWorkspace() {
- const [decisions, setDecisions] = useState([]);
- const [selectedDecision, setSelectedDecision] = useState(null);
- const [chain, setChain] = useState([]);
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- fetch("/api/decisions")
- .then(res => res.json())
- .then(data => {
- setDecisions(data);
- if (data.length > 0) handleSelectDecision(data[0]);
- })
- .catch(console.error);
- }, []);
-
- const handleSelectDecision = async (d: any) => {
- setSelectedDecision(d);
- setLoading(true);
- try {
- const res = await fetch(`/api/decisions/${d.decision_id}/chain`);
- const data = await res.json();
- setChain(data.chain || []);
- } catch (e) {
- console.error(e);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
-
- {/* Left Column: Decisions List */}
-
-
- Decision Tree
-
-
- {decisions.map(d => (
-
handleSelectDecision(d)}
- style={{
- textAlign: "left", padding: "12px 16px", borderRadius: 8, cursor: "pointer",
- background: selectedDecision?.decision_id === d.decision_id ? "rgba(88,166,255,0.15)" : "transparent",
- border: `1px solid ${selectedDecision?.decision_id === d.decision_id ? "#58a6ff" : "rgba(255,255,255,0.1)"}`,
- color: selectedDecision?.decision_id === d.decision_id ? "#ffffff" : "#c9d1d9",
- transition: "all 0.2s"
- }}
- >
- {d.decision_id}
- {d.category || 'Uncategorized'}
-
- ))}
-
-
-
- {/* Right Column: Causal Chains */}
-
-
-
- {selectedDecision ? (
- <>
-
{selectedDecision.decision_id}
-
Outcome: {selectedDecision.outcome}
-
-
-
Causal Chain
- {loading ? (
-
Loading chain...
- ) : chain.length > 0 ? (
- chain.map((c, i) => (
-
- ))
- ) : (
-
No causal chain found.
- )}
-
- >
- ) : (
-
Select a decision to view details
- )}
-
-
- );
-}
diff --git a/semantica/__init__.py b/semantica/__init__.py
index 0d6a5eba..1bb20a40 100644
--- a/semantica/__init__.py
+++ b/semantica/__init__.py
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
-__version__ = "0.3.0"
+__version__ = "0.4.0"
__author__ = "Semantica Contributors"
__license__ = "MIT"
diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py
index a9fa25ec..bcc625d5 100644
--- a/semantica/explorer/app.py
+++ b/semantica/explorer/app.py
@@ -10,7 +10,7 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
@@ -19,7 +19,12 @@ from .ws import ConnectionManager
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
+ previous_callback = getattr(session.graph, "mutation_callback", None)
+
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
+ session.handle_graph_mutation(event_type, entity_id, payload)
+ if callable(previous_callback):
+ previous_callback(event_type, entity_id, payload)
loop = getattr(app.state, "event_loop", None)
manager = getattr(app.state, "ws_manager", None)
if loop is None or manager is None or loop.is_closed():
@@ -127,6 +132,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
except WebSocketDisconnect:
manager.disconnect(websocket)
+ @app.get("/", include_in_schema=False)
+ async def root():
+ index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
+ if index_path.is_file():
+ return FileResponse(index_path)
+ return HTMLResponse(
+ ' '
+ 'Semantica Knowledge Explorer '
+ '
'
+ )
+
@app.get("/api/health")
async def health():
return {"status": "healthy"}
@@ -139,6 +155,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
"status": "active",
}
+ @app.get("/", include_in_schema=False)
+ async def root():
+ index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
+ if index_path.is_file():
+ return FileResponse(index_path)
+ return HTMLResponse(
+ ' '
+ 'Semantica Knowledge Explorer '
+ '
'
+ )
+
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
assets_dir = static_dir / "assets"
diff --git a/semantica/explorer/routes/annotations.py b/semantica/explorer/routes/annotations.py
index f32f478d..4b649f02 100644
--- a/semantica/explorer/routes/annotations.py
+++ b/semantica/explorer/routes/annotations.py
@@ -8,7 +8,7 @@ modify Semantica core.
import asyncio
from typing import Optional
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
from ..dependencies import get_session
from ..schemas import AnnotationCreate, AnnotationResponse
@@ -35,7 +35,7 @@ async def create_annotation(
"""Create a new annotation on a node."""
node = await asyncio.to_thread(session.get_node, body.node_id)
if node is None:
- raise KeyError(body.node_id)
+ raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
ann_data = body.model_dump()
ann_id = await asyncio.to_thread(session.add_annotation, ann_data)
@@ -61,5 +61,5 @@ async def delete_annotation(
"""Delete an annotation by ID."""
deleted = await asyncio.to_thread(session.delete_annotation, annotation_id)
if not deleted:
- raise KeyError(annotation_id)
+ raise HTTPException(status_code=404, detail=f"Annotation '{annotation_id}' not found")
return None
diff --git a/semantica/explorer/routes/decisions.py b/semantica/explorer/routes/decisions.py
index fdea8d2c..159c6392 100644
--- a/semantica/explorer/routes/decisions.py
+++ b/semantica/explorer/routes/decisions.py
@@ -5,7 +5,7 @@ Decision routes using ContextGraph-native fallbacks.
import asyncio
from typing import Optional
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
from ..dependencies import get_session
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
@@ -59,7 +59,7 @@ async def get_decision(
):
node = await asyncio.to_thread(session.get_node, decision_id)
if node is None:
- raise KeyError(decision_id)
+ raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
return _node_to_decision(node)
@@ -70,7 +70,7 @@ async def get_causal_chain(
):
node = await asyncio.to_thread(session.get_node, decision_id)
if node is None:
- raise KeyError(decision_id)
+ raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, 5)
chain = [
@@ -94,7 +94,7 @@ async def get_precedents(
):
node = await asyncio.to_thread(session.get_node, decision_id)
if node is None:
- raise KeyError(decision_id)
+ raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
properties = node.get("properties", {})
category = str(properties.get("category", ""))
@@ -132,7 +132,7 @@ async def check_compliance(
):
node = await asyncio.to_thread(session.get_node, decision_id)
if node is None:
- raise KeyError(decision_id)
+ raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
violation_types = {"violates", "non_compliant", "breaches"}
diff --git a/semantica/explorer/routes/enrich.py b/semantica/explorer/routes/enrich.py
index be87c783..e6498d9c 100644
--- a/semantica/explorer/routes/enrich.py
+++ b/semantica/explorer/routes/enrich.py
@@ -6,7 +6,7 @@ import asyncio
import re
from typing import Dict, List, Optional, Tuple
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException
from ..dependencies import get_session
from ..schemas import (
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
continue
source, target = args
if session.get_node(source) is None:
- session.graph.add_node(source, "entity", content=source)
+ session.add_node(source, "entity", content=source)
if session.get_node(target) is None:
- session.graph.add_node(target, "entity", content=target)
+ session.add_node(target, "entity", content=target)
edge_type = body.inferred_edge_type or predicate
- session.graph.add_edge(
+ session.add_edge(
source,
target,
edge_type=edge_type,
@@ -173,11 +173,12 @@ async def extract_entities(
relations=[_safe_dict(relation) for relation in rel_list],
)
except ImportError:
- raise ValueError(
- "semantic_extract module not available. Ensure spacy and transformers are installed."
+ raise HTTPException(
+ status_code=503,
+ detail="semantic_extract module not available. Ensure spacy and transformers are installed.",
)
except Exception as exc:
- raise ValueError(f"Extraction failed: {exc}")
+ raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}")
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
@@ -187,11 +188,11 @@ async def predict_links(
):
predictor = session.link_predictor
if predictor is None:
- raise ValueError("LinkPredictor not available; KG extras may not be installed.")
+ raise HTTPException(status_code=503, detail="LinkPredictor not available; KG extras may not be installed.")
node = await asyncio.to_thread(session.get_node, body.node_id)
if node is None:
- raise KeyError(body.node_id)
+ raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
@@ -248,9 +249,9 @@ async def detect_duplicates(
duplicate_list = duplicates if isinstance(duplicates, list) else getattr(duplicates, "duplicates", [])
return DedupResponse(duplicates=[_safe_dict(item) for item in duplicate_list], total_flagged=len(duplicate_list))
except ImportError:
- raise ValueError("Deduplication module not available.")
+ raise HTTPException(status_code=503, detail="Deduplication module not available.")
except Exception as exc:
- raise ValueError(f"Dedup scan failed: {exc}")
+ raise HTTPException(status_code=422, detail=f"Dedup scan failed: {exc}")
@router.post("/api/reason", response_model=ReasoningResponse)
@@ -295,7 +296,7 @@ async def merge_nodes(
node = await asyncio.to_thread(session.get_node, primary_id)
if node is None:
- raise ValueError(f"Primary node {primary_id} not found")
+ raise HTTPException(status_code=404, detail=f"Primary node '{primary_id}' not found")
def _do_merge() -> tuple[list[str], int]:
removed: list[str] = []
@@ -353,4 +354,6 @@ async def merge_nodes(
return removed, edges_updated
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
+ if removed_ids:
+ await asyncio.to_thread(session.rebuild_search_index)
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
diff --git a/semantica/explorer/routes/graph.py b/semantica/explorer/routes/graph.py
index 92da3bc7..3ad458d2 100644
--- a/semantica/explorer/routes/graph.py
+++ b/semantica/explorer/routes/graph.py
@@ -6,8 +6,9 @@ import asyncio
from enum import Enum
from typing import Optional
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
+from ...utils.helpers import classify_path_distance
from ..dependencies import get_session
from ..schemas import (
EdgeListResponse,
@@ -83,7 +84,7 @@ async def get_node(
):
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
- raise KeyError(node_id)
+ raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
return _node_response(node)
@@ -141,16 +142,19 @@ class _PathAlgorithm(str, Enum):
dijkstra = "dijkstra"
+
+
@router.get("/node/{node_id}/path", response_model=PathResponse)
async def find_path(
node_id: str,
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
+ directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
path_finder = session.path_finder
if path_finder is None:
- raise ValueError("PathFinder not available; KG extras may not be installed.")
+ raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
graph_dict = await asyncio.to_thread(session.build_graph_dict)
path_fn = (
@@ -158,12 +162,19 @@ async def find_path(
if algorithm == _PathAlgorithm.dijkstra
else path_finder.bfs_shortest_path
)
- result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
+ try:
+ result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
+ except Exception as exc:
+ raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
+ if not path_nodes:
+ raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
+
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
+ hop_count = len(path_nodes) - 1 if path_nodes else 0
return PathResponse(
source=node_id,
target=target,
@@ -171,6 +182,9 @@ async def find_path(
path=path_nodes,
edge_ids=edge_ids,
total_weight=total_weight,
+ directed=directed,
+ hop_count=hop_count,
+ distance_band=classify_path_distance(hop_count),
)
diff --git a/semantica/explorer/routes/provenance.py b/semantica/explorer/routes/provenance.py
index 551c2c50..8b1b1f03 100644
--- a/semantica/explorer/routes/provenance.py
+++ b/semantica/explorer/routes/provenance.py
@@ -1,4 +1,4 @@
-"""
+"""
Provenance routes for lineage visualization and exportable reports.
"""
@@ -9,33 +9,13 @@ from typing import Any, Dict, List, Optional
import networkx as nx
from fastapi import APIRouter, Depends, Query
from fastapi.responses import PlainTextResponse, Response
-from pydantic import BaseModel
from ..dependencies import get_session
+from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
from ..session import GraphSession
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
-
-class ProvenanceNode(BaseModel):
- id: str
- label: str
- prov_type: str
- parent_id: str
-
-
-class ProvenanceEdge(BaseModel):
- id: str
- source: str
- target: str
- label: str
-
-
-class ProvenanceResponse(BaseModel):
- nodes: List[ProvenanceNode]
- edges: List[ProvenanceEdge]
-
-
_AGENT_TYPES = {"person", "organization", "system", "agent"}
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
@@ -67,7 +47,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
- subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
+ subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
provenance_nodes: List[Dict[str, Any]] = []
for graph_node_id in subgraph.nodes():
node = session.graph.nodes.get(graph_node_id)
@@ -85,12 +65,19 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
provenance_edges: List[Dict[str, Any]] = []
for source, target, data in subgraph.edges(data=True):
+ if target == node_id:
+ direction = "upstream"
+ elif source == node_id:
+ direction = "downstream"
+ else:
+ direction = "lateral"
provenance_edges.append(
{
"id": f"{source}-{target}",
"source": source,
"target": target,
"label": data.get("label", "related_to"),
+ "direction": direction,
}
)
@@ -104,7 +91,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
"node_id": node_id,
"label": node.get("content", node_id) if node else node_id,
"type": node.get("type", "entity") if node else "entity",
- "properties": node.get("properties", {}) if node else {},
+ "properties": node.get("metadata", node.get("properties", {})) if node else {},
"lineage": provenance,
}
@@ -129,9 +116,29 @@ def _render_markdown(report: Dict[str, Any]) -> str:
for node in report.get("lineage", {}).get("nodes", []):
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
- lines.extend(["", "## Lineage Edges"])
- for edge in report.get("lineage", {}).get("edges", []):
- lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
+ edges = report.get("lineage", {}).get("edges", [])
+ grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
+ for edge in edges:
+ direction = edge.get("direction", "lateral")
+ if direction not in grouped_edges:
+ direction = "lateral"
+ grouped_edges[direction].append(edge)
+
+ if grouped_edges["upstream"]:
+ lines.extend(["", "## Upstream"])
+ for edge in grouped_edges["upstream"]:
+ lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
+
+ if grouped_edges["downstream"]:
+ lines.extend(["", "## Downstream"])
+ for edge in grouped_edges["downstream"]:
+ lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
+
+ if grouped_edges["lateral"]:
+ lines.extend(["", "## Lateral"])
+ for edge in grouped_edges["lateral"]:
+ lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
+
return "\n".join(lines)
diff --git a/semantica/explorer/routes/temporal.py b/semantica/explorer/routes/temporal.py
index 04167ba3..4da29d8c 100644
--- a/semantica/explorer/routes/temporal.py
+++ b/semantica/explorer/routes/temporal.py
@@ -103,7 +103,7 @@ async def temporal_patterns(
detector = TemporalPatternDetector()
graph_dict = await asyncio.to_thread(session.build_graph_dict)
- patterns = await asyncio.to_thread(detector.detect_patterns, graph_dict)
+ patterns = await asyncio.to_thread(detector.detect_temporal_patterns, graph_dict)
if isinstance(patterns, dict):
patterns = patterns.get("patterns", [])
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
diff --git a/semantica/explorer/schemas.py b/semantica/explorer/schemas.py
index 9bdfbbca..46592de8 100644
--- a/semantica/explorer/schemas.py
+++ b/semantica/explorer/schemas.py
@@ -67,6 +67,9 @@ class PathResponse(BaseModel):
path: List[str]
edge_ids: List[str] = Field(default_factory=list)
total_weight: float = 0.0
+ directed: bool = True
+ hop_count: int = 0
+ distance_band: str = "direct"
class GraphStatsResponse(BaseModel):
@@ -285,3 +288,23 @@ class MergeResponse(BaseModel):
merged_into: str
removed_ids: List[str]
edges_updated: int
+
+
+class ProvenanceNode(BaseModel):
+ id: str
+ label: str
+ prov_type: str
+ parent_id: Optional[str] = None
+
+
+class ProvenanceEdge(BaseModel):
+ id: str
+ source: str
+ target: str
+ label: str
+ direction: str
+
+
+class ProvenanceResponse(BaseModel):
+ nodes: List[ProvenanceNode]
+ edges: List[ProvenanceEdge]
diff --git a/semantica/explorer/search_index.py b/semantica/explorer/search_index.py
new file mode 100644
index 00000000..d13372ce
--- /dev/null
+++ b/semantica/explorer/search_index.py
@@ -0,0 +1,398 @@
+"""
+Explorer-local in-memory node search index.
+"""
+
+from __future__ import annotations
+
+import bisect
+import heapq
+import re
+from collections import OrderedDict, defaultdict
+from dataclasses import dataclass
+from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple
+
+_TOKEN_RE = re.compile(r"[a-z0-9]+")
+_WHITESPACE_RE = re.compile(r"\s+")
+_CURATED_ALIAS_KEYS = (
+ "label",
+ "name",
+ "title",
+ "pref_label",
+ "preferred_label",
+ "prefLabel",
+ "aliases",
+ "alias",
+ "synonyms",
+ "synonym",
+ "symbol",
+ "display_name",
+ "displayName",
+ "text",
+ "content",
+)
+
+
+def _normalize_text(value: Any) -> str:
+ if value is None:
+ return ""
+ text = str(value).strip().lower()
+ if not text:
+ return ""
+ return _WHITESPACE_RE.sub(" ", text)
+
+
+def _tokenize(text: str) -> Tuple[str, ...]:
+ if not text:
+ return ()
+ return tuple(dict.fromkeys(_TOKEN_RE.findall(text)))
+
+
+def _collect_text_fragments(value: Any, fragments: List[str], *, limit: int = 64) -> None:
+ if value is None or len(fragments) >= limit:
+ return
+ if isinstance(value, dict):
+ for nested in value.values():
+ _collect_text_fragments(nested, fragments, limit=limit)
+ if len(fragments) >= limit:
+ return
+ return
+ if isinstance(value, (list, tuple, set)):
+ for nested in value:
+ _collect_text_fragments(nested, fragments, limit=limit)
+ if len(fragments) >= limit:
+ return
+ return
+
+ normalized = _normalize_text(value)
+ if normalized:
+ fragments.append(normalized)
+
+
+def _coerce_float(value: Any) -> Optional[float]:
+ if value is None or value == "":
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+@dataclass(frozen=True)
+class IndexedNodeDocument:
+ node_id: str
+ normalized_id: str
+ node_type: str
+ exact_terms: frozenset[str]
+ tokens: frozenset[str]
+ primary_text: str
+ secondary_text: str
+ confidence: Optional[float]
+ tags: Tuple[str, ...]
+
+
+class GraphSearchIndex:
+ def __init__(
+ self,
+ *,
+ cache_size: int = 128,
+ prefix_min_length: int = 2,
+ prefix_max_length: int = 12,
+ secondary_scan_limit: int = 12000,
+ ) -> None:
+ self.cache_size = cache_size
+ self.prefix_min_length = prefix_min_length
+ self.prefix_max_length = prefix_max_length
+ self.secondary_scan_limit = secondary_scan_limit
+ self._documents: Dict[str, IndexedNodeDocument] = {}
+ self._exact_index: DefaultDict[str, set[str]] = defaultdict(set)
+ self._token_index: DefaultDict[str, set[str]] = defaultdict(set)
+ self._prefix_index: DefaultDict[str, set[str]] = defaultdict(set)
+ self._ordered_node_ids: List[str] = []
+ self._cache: OrderedDict[Tuple[Any, ...], List[Tuple[str, float]]] = OrderedDict()
+
+ def rebuild(self, nodes: Iterable[Dict[str, Any]]) -> None:
+ self._documents.clear()
+ self._exact_index.clear()
+ self._token_index.clear()
+ self._prefix_index.clear()
+ self._ordered_node_ids = []
+ self.clear_cache()
+
+ for node in nodes:
+ self.upsert(node, clear_cache=False)
+
+ self._ordered_node_ids.sort()
+
+ def clear_cache(self) -> None:
+ self._cache.clear()
+
+ def remove(self, node_id: str, *, clear_cache: bool = True) -> None:
+ existing = self._documents.pop(node_id, None)
+ if existing is None:
+ return
+
+ for term in existing.exact_terms:
+ bucket = self._exact_index.get(term)
+ if bucket is None:
+ continue
+ bucket.discard(node_id)
+ if not bucket:
+ self._exact_index.pop(term, None)
+
+ for token in existing.tokens:
+ bucket = self._token_index.get(token)
+ if bucket is None:
+ continue
+ bucket.discard(node_id)
+ if not bucket:
+ self._token_index.pop(token, None)
+
+ for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
+ prefix = token[:length]
+ prefix_bucket = self._prefix_index.get(prefix)
+ if prefix_bucket is None:
+ continue
+ prefix_bucket.discard(node_id)
+ if not prefix_bucket:
+ self._prefix_index.pop(prefix, None)
+
+ pos = bisect.bisect_left(self._ordered_node_ids, node_id)
+ if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
+ self._ordered_node_ids.pop(pos)
+
+ if clear_cache:
+ self.clear_cache()
+
+ def upsert(self, node: Dict[str, Any], *, clear_cache: bool = True) -> None:
+ node_id = str(node.get("id", "")).strip()
+ if not node_id:
+ return
+
+ self.remove(node_id, clear_cache=False)
+ document = self._build_document(node)
+ self._documents[node_id] = document
+
+ for term in document.exact_terms:
+ self._exact_index[term].add(node_id)
+
+ for token in document.tokens:
+ self._token_index[token].add(node_id)
+ for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
+ self._prefix_index[token[:length]].add(node_id)
+
+ bisect.insort(self._ordered_node_ids, node_id)
+
+ if clear_cache:
+ self.clear_cache()
+
+ def search(
+ self,
+ query: str,
+ *,
+ limit: int = 20,
+ filters: Optional[Dict[str, Any]] = None,
+ ) -> tuple[List[Tuple[str, float]], Dict[str, Any]]:
+ normalized_query = _normalize_text(query)
+ filters = filters or {}
+ diagnostics: Dict[str, Any] = {
+ "cache_hit": False,
+ "path": "empty",
+ "candidates": 0,
+ }
+ if not normalized_query:
+ return [], diagnostics
+
+ cache_key = self._cache_key(normalized_query, limit, filters)
+ cached = self._cache.get(cache_key)
+ if cached is not None:
+ self._cache.move_to_end(cache_key)
+ diagnostics.update({"cache_hit": True, "path": "cache", "candidates": len(cached)})
+ return list(cached), diagnostics
+
+ query_tokens = _tokenize(normalized_query)
+ exact_ids = set(self._exact_index.get(normalized_query, set()))
+ token_sets: List[set[str]] = []
+ prefix_sets: List[set[str]] = []
+ for token in query_tokens:
+ exact_token_ids = set(self._token_index.get(token, set()))
+ prefix_ids = set(self._prefix_index.get(token, set())) if len(token) >= self.prefix_min_length else set()
+ if exact_token_ids:
+ token_sets.append(exact_token_ids)
+ if prefix_ids:
+ prefix_sets.append(prefix_ids)
+
+ candidate_ids: set[str] = set(exact_ids)
+ if token_sets:
+ intersected = set.intersection(*token_sets)
+ candidate_ids.update(intersected if intersected else set().union(*token_sets))
+ if prefix_sets:
+ candidate_ids.update(set().union(*prefix_sets))
+
+ diagnostics["path"] = "index"
+
+ if not candidate_ids:
+ diagnostics["path"] = "secondary_scan"
+ candidate_ids = self._secondary_scan(normalized_query, limit)
+
+ diagnostics["candidates"] = len(candidate_ids)
+
+ scored: List[Tuple[float, int, int, str]] = []
+ for node_id in candidate_ids:
+ document = self._documents.get(node_id)
+ if document is None or not self._passes_filters(document, filters):
+ continue
+ score = self._score_document(document, normalized_query, query_tokens)
+ if score <= 0:
+ continue
+ token_hits = sum(1 for token in query_tokens if token in document.tokens)
+ exactness = 1 if normalized_query == document.normalized_id or normalized_query in document.exact_terms else 0
+ scored.append((score, exactness, token_hits, node_id))
+
+ top_matches = heapq.nlargest(limit, scored, key=lambda item: (item[0], item[1], item[2], item[3]))
+ results = [(node_id, round(score, 4)) for score, _, _, node_id in top_matches]
+ self._store_cache(cache_key, results)
+ return results, diagnostics
+
+ def _secondary_scan(self, normalized_query: str, limit: int) -> set[str]:
+ matches: set[str] = set()
+ max_hits = max(limit * 20, 200)
+ scanned = 0
+ for node_id in self._ordered_node_ids:
+ if scanned >= self.secondary_scan_limit or len(matches) >= max_hits:
+ break
+ scanned += 1
+ document = self._documents.get(node_id)
+ if document is None:
+ continue
+ if normalized_query in document.primary_text or normalized_query in document.secondary_text:
+ matches.add(node_id)
+ return matches
+
+ def _score_document(
+ self,
+ document: IndexedNodeDocument,
+ normalized_query: str,
+ query_tokens: Tuple[str, ...],
+ ) -> float:
+ score = 0.0
+ if normalized_query == document.normalized_id:
+ score = max(score, 140.0)
+ elif normalized_query in document.exact_terms:
+ score = max(score, 120.0)
+
+ if normalized_query and normalized_query in document.primary_text:
+ score = max(score, 78.0 + min(len(normalized_query), 24) / 10.0)
+ elif normalized_query and normalized_query in document.secondary_text:
+ score = max(score, 26.0 + min(len(normalized_query), 24) / 20.0)
+
+ token_hits = 0
+ prefix_hits = 0
+ for token in query_tokens:
+ if token in document.tokens:
+ token_hits += 1
+ elif len(token) >= self.prefix_min_length and any(candidate.startswith(token) for candidate in document.tokens):
+ prefix_hits += 1
+
+ score += token_hits * 18.0
+ score += prefix_hits * 10.0
+
+ if len(query_tokens) > 1 and token_hits:
+ score += token_hits * 4.0
+
+ return score
+
+ def _passes_filters(self, document: IndexedNodeDocument, filters: Dict[str, Any]) -> bool:
+ filter_type = filters.get("type") or filters.get("node_type")
+ if filter_type and document.node_type != str(filter_type):
+ return False
+
+ min_confidence = _coerce_float(filters.get("min_confidence"))
+ if min_confidence is not None:
+ if document.confidence is None or document.confidence < min_confidence:
+ return False
+
+ tags_filter = filters.get("tags")
+ if tags_filter:
+ if isinstance(tags_filter, str):
+ required_tags = {_normalize_text(tags_filter)}
+ else:
+ required_tags = {
+ normalized
+ for normalized in (_normalize_text(tag) for tag in tags_filter)
+ if normalized
+ }
+ if required_tags and not required_tags.issubset(set(document.tags)):
+ return False
+
+ return True
+
+ def _cache_key(
+ self,
+ normalized_query: str,
+ limit: int,
+ filters: Dict[str, Any],
+ ) -> Tuple[Any, ...]:
+ serialized_filters: List[Tuple[str, Any]] = []
+ for key in sorted(filters.keys()):
+ value = filters[key]
+ if isinstance(value, (list, tuple, set)):
+ serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
+ else:
+ serialized_filters.append((key, str(value)))
+ return normalized_query, limit, tuple(serialized_filters)
+
+ def _store_cache(self, cache_key: Tuple[Any, ...], results: List[Tuple[str, float]]) -> None:
+ self._cache[cache_key] = list(results)
+ self._cache.move_to_end(cache_key)
+ while len(self._cache) > self.cache_size:
+ self._cache.popitem(last=False)
+
+ def _build_document(self, node: Dict[str, Any]) -> IndexedNodeDocument:
+ node_id = str(node.get("id", "")).strip()
+ node_type = str(node.get("type", "entity"))
+ properties = dict(node.get("properties", {}) or {})
+
+ primary_terms: List[str] = []
+ for candidate in (node_id, node.get("content", "")):
+ normalized = _normalize_text(candidate)
+ if normalized:
+ primary_terms.append(normalized)
+
+ for alias_key in _CURATED_ALIAS_KEYS:
+ _collect_text_fragments(properties.get(alias_key), primary_terms, limit=32)
+
+ deduped_primary_terms = tuple(dict.fromkeys(term for term in primary_terms if term))
+ primary_text = " ".join(deduped_primary_terms)
+ tokens = frozenset(_tokenize(primary_text))
+
+ secondary_fragments: List[str] = []
+ for key, value in properties.items():
+ if key in _CURATED_ALIAS_KEYS or key in {"content", "valid_from", "valid_until"}:
+ continue
+ _collect_text_fragments(value, secondary_fragments, limit=48)
+ if len(secondary_fragments) >= 48:
+ break
+
+ secondary_text = " ".join(dict.fromkeys(fragment for fragment in secondary_fragments if fragment))
+ confidence = _coerce_float(properties.get("confidence"))
+
+ raw_tags = properties.get("tags") or []
+ if isinstance(raw_tags, str):
+ raw_tags = [raw_tags]
+ tags = tuple(
+ dict.fromkeys(
+ normalized for normalized in (_normalize_text(tag) for tag in raw_tags) if normalized
+ )
+ )
+
+ return IndexedNodeDocument(
+ node_id=node_id,
+ normalized_id=_normalize_text(node_id),
+ node_type=node_type,
+ exact_terms=frozenset(deduped_primary_terms),
+ tokens=tokens,
+ primary_text=primary_text,
+ secondary_text=secondary_text,
+ confidence=confidence,
+ tags=tags,
+ )
diff --git a/semantica/explorer/session.py b/semantica/explorer/session.py
index 9600f90f..059fdea7 100644
--- a/semantica/explorer/session.py
+++ b/semantica/explorer/session.py
@@ -4,12 +4,15 @@ Semantica Explorer session helpers.
import base64
import json
+import logging
import threading
+import time
import uuid
-from datetime import datetime, UTC
+from datetime import UTC, datetime
from typing import Any, Dict, Iterable, List, Optional
from ..context.context_graph import ContextGraph, _resolve_edge_identity
+from .search_index import GraphSearchIndex
_KG_AVAILABLE = False
try:
@@ -28,6 +31,8 @@ try:
except ImportError:
pass
+logger = logging.getLogger(__name__)
+
class GraphSession:
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
@@ -35,6 +40,7 @@ class GraphSession:
def __init__(self, graph: ContextGraph) -> None:
self.graph = graph
self._lock = threading.RLock()
+ self._search_index = GraphSearchIndex()
self.annotations: Dict[str, Dict[str, Any]] = {}
@@ -46,6 +52,7 @@ class GraphSession:
self._similarity: Any = None
self._link_predictor: Any = None
self._validator: Any = None
+ self.rebuild_search_index()
@classmethod
def from_file(cls, path: str) -> "GraphSession":
@@ -390,6 +397,28 @@ class GraphSession:
with self._lock:
return self.graph.get_neighbors(node_id, hops=depth)
+ def rebuild_search_index(self) -> None:
+ with self._lock:
+ normalized_nodes = [
+ self.normalize_node(node.to_dict())
+ for node in self.graph.nodes.values()
+ if node is not None
+ ]
+ self._search_index.rebuild(normalized_nodes)
+
+ def handle_graph_mutation(self, event_type: str, entity_id: str, payload: Dict[str, Any]) -> None:
+ normalized_event = str(event_type or "").upper()
+ if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
+ normalized_node = self.normalize_node(payload or {})
+ if normalized_node.get("id"):
+ with self._lock:
+ self._search_index.upsert(normalized_node)
+ elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
+ with self._lock:
+ self._search_index.remove(str(entity_id))
+ elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
+ self.rebuild_search_index()
+
def search(
self,
query: str,
@@ -397,64 +426,34 @@ class GraphSession:
filters: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
filters = filters or {}
- try:
- with self._lock:
- raw = self.graph.query(query)[:limit]
- except Exception:
- raw = []
+ started_at = time.perf_counter()
+ matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
- if not raw:
- nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
- scored = []
- lowered_query = query.lower().strip()
- for node in nodes:
- haystacks = [
- str(node.get("id", "")),
- str(node.get("content", "")),
- json.dumps(node.get("properties", {}), default=str),
- ]
- best_score = 0.0
- for haystack in haystacks:
- lowered = haystack.lower()
- if lowered == lowered_query:
- best_score = max(best_score, 1.0)
- elif lowered_query in lowered:
- best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
- if best_score > 0:
- scored.append({"node": node, "score": round(best_score, 4)})
- raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
-
- normalized = []
- for result in raw:
- result_node = result.get("node", {})
- node = (
- self.normalize_node(result_node)
- if "properties" in result_node or "metadata" in result_node or "content" in result_node
- else result_node
- )
-
- filter_type = filters.get("type") or filters.get("node_type")
- if filter_type and node["type"] != filter_type:
- continue
-
- min_confidence = self._coerce_float(filters.get("min_confidence"))
- node_confidence = self._coerce_float(node["properties"].get("confidence"))
- if min_confidence is not None and (
- node_confidence is None or node_confidence < min_confidence
- ):
- continue
-
- tags_filter = filters.get("tags")
- if tags_filter:
- node_tags = node["properties"].get("tags") or []
- if isinstance(node_tags, str):
- node_tags = [node_tags]
- if not set(tags_filter).issubset(set(node_tags)):
+ normalized_results: List[Dict[str, Any]] = []
+ with self._lock:
+ for node_id, score in matches:
+ raw_node = self.graph.find_node(node_id)
+ if raw_node is None:
continue
+ node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
+ normalized_results.append(
+ {
+ "node": self.normalize_node(node_payload),
+ "score": score,
+ }
+ )
- normalized.append({"node": node, "score": result.get("score", 0.0)})
-
- return normalized[:limit]
+ duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
+ logger.debug(
+ "Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
+ query,
+ limit,
+ diagnostics.get("cache_hit"),
+ diagnostics.get("path"),
+ diagnostics.get("candidates"),
+ duration_ms,
+ )
+ return normalized_results[:limit]
def get_stats(self) -> Dict[str, Any]:
with self._lock:
@@ -594,8 +593,51 @@ class GraphSession:
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
with self._lock:
- return self.graph.add_nodes(nodes)
+ added = self.graph.add_nodes(nodes)
+ has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
+ if added and not has_mutation_callback:
+ self.rebuild_search_index()
+ return added
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
with self._lock:
- return self.graph.add_edges(edges)
+ added = self.graph.add_edges(edges)
+ has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
+ if added and not has_mutation_callback:
+ self.rebuild_search_index()
+ return added
+
+ def add_node(
+ self,
+ node_id: str,
+ node_type: str,
+ content: Optional[str] = None,
+ **properties: Any,
+ ) -> bool:
+ with self._lock:
+ added = self.graph.add_node(node_id, node_type, content=content, **properties)
+ has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
+ if added and not has_mutation_callback:
+ normalized = self.get_node(node_id)
+ if normalized is not None:
+ self._search_index.upsert(normalized)
+ return added
+
+ def add_edge(
+ self,
+ source_id: str,
+ target_id: str,
+ edge_type: str = "related_to",
+ weight: float = 1.0,
+ **properties: Any,
+ ) -> bool:
+ with self._lock:
+ added = self.graph.add_edge(
+ source_id,
+ target_id,
+ edge_type=edge_type,
+ weight=weight,
+ **properties,
+ )
+ has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
+ return added
diff --git a/semantica/export/owl_exporter.py b/semantica/export/owl_exporter.py
index 7ef02f11..5f123f0c 100644
--- a/semantica/export/owl_exporter.py
+++ b/semantica/export/owl_exporter.py
@@ -328,6 +328,18 @@ class OWLExporter:
lines.append("")
return "\n".join(lines)
+ @staticmethod
+ def _escape_ttl_str(value: str) -> str:
+ """Escape a string value for safe embedding in a Turtle string literal."""
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
+
+ def _ttl_block(self, subject_uri: str, rdf_type: str, predicates: List[str]) -> str:
+ """Build a valid Turtle subject block from accumulated predicate strings."""
+ stmt = f"<{subject_uri}> a {rdf_type}"
+ for pred in predicates:
+ stmt += f" ;\n {pred}"
+ return stmt + " ."
+
def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str:
"""
Export ontology to OWL Turtle format.
@@ -342,6 +354,7 @@ class OWLExporter:
Returns:
String containing OWL Turtle serialization
"""
+ esc = self._escape_ttl_str
ontology_uri = ontology.get("uri") or self.ontology_uri
ontology_name = ontology.get("name", "SemanticaOntology")
version = ontology.get("version") or self.version
@@ -357,63 +370,73 @@ class OWLExporter:
lines.append("")
# Ontology declaration
- lines.append(f"<{ontology_uri}> a owl:Ontology ;")
- lines.append(f' rdfs:label "{ontology_name}" ;')
- lines.append(f' owl:versionInfo "{version}" .')
- if ontology.get("description"):
- lines.append(f' rdfs:comment "{ontology.get("description")}" ;')
+ onto_predicates = [
+ f'rdfs:label "{esc(ontology_name)}"',
+ f'owl:versionInfo "{esc(version)}"',
+ ]
+ description = ontology.get("description")
+ if description:
+ onto_predicates.append(f'rdfs:comment "{esc(description)}"')
+ lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
lines.append("")
# Classes
- classes = ontology.get("classes", [])
- for cls in classes:
+ for cls in ontology.get("classes", []):
class_uri = cls.get("uri") or cls.get("id", "")
class_name = cls.get("name") or cls.get("label", "")
-
- lines.append(f"<{class_uri}> a owl:Class ;")
- lines.append(f' rdfs:label "{class_name}" .')
-
- if cls.get("comment"):
- lines.append(f' rdfs:comment "{cls.get("comment")}" ;')
-
- if cls.get("subClassOf"):
- parent = cls.get("subClassOf")
- lines.append(f" rdfs:subClassOf <{parent}> ;")
-
- # Remove trailing semicolon and add period
- if lines[-1].endswith(" ;"):
- lines[-1] = lines[-1].rstrip(" ;") + " ."
- else:
- lines.append(" .")
+ predicates = [f'rdfs:label "{esc(class_name)}"']
+ comment = cls.get("comment")
+ if comment:
+ predicates.append(f'rdfs:comment "{esc(comment)}"')
+ sub_class = cls.get("subClassOf")
+ if sub_class:
+ predicates.append(f"rdfs:subClassOf <{sub_class}>")
+ equiv = cls.get("equivalentClass")
+ if equiv:
+ predicates.append(f"owl:equivalentClass <{equiv}>")
+ lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
lines.append("")
# Object properties
- object_properties = ontology.get("object_properties", [])
- for prop in object_properties:
+ for prop in ontology.get("object_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
prop_name = prop.get("name") or prop.get("label", "")
-
- lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
- lines.append(f' rdfs:label "{prop_name}" .')
-
- if prop.get("domain"):
- domain = prop.get("domain")
+ predicates = [f'rdfs:label "{esc(prop_name)}"']
+ comment = prop.get("comment")
+ if comment:
+ predicates.append(f'rdfs:comment "{esc(comment)}"')
+ domain = prop.get("domain")
+ if domain:
if isinstance(domain, list):
for d in domain:
- lines.append(f" rdfs:domain <{d}> ;")
+ predicates.append(f"rdfs:domain <{d}>")
else:
- lines.append(f" rdfs:domain <{domain}> ;")
-
- if prop.get("range"):
- range_val = prop.get("range")
+ predicates.append(f"rdfs:domain <{domain}>")
+ range_val = prop.get("range")
+ if range_val:
if isinstance(range_val, list):
for r in range_val:
- lines.append(f" rdfs:range <{r}> ;")
+ predicates.append(f"rdfs:range <{r}>")
else:
- lines.append(f" rdfs:range <{range_val}> ;")
+ predicates.append(f"rdfs:range <{range_val}>")
+ lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
+ lines.append("")
- if lines[-1].endswith(" ;"):
- lines[-1] = lines[-1].rstrip(" ;") + " ."
+ # Data properties
+ for prop in ontology.get("data_properties", []):
+ prop_uri = prop.get("uri") or prop.get("id", "")
+ prop_name = prop.get("name") or prop.get("label", "")
+ predicates = [f'rdfs:label "{esc(prop_name)}"']
+ comment = prop.get("comment")
+ if comment:
+ predicates.append(f'rdfs:comment "{esc(comment)}"')
+ domain = prop.get("domain")
+ if domain:
+ predicates.append(f"rdfs:domain <{domain}>")
+ range_type = prop.get("range")
+ if range_type:
+ predicates.append(f"rdfs:range xsd:{range_type}")
+ lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
lines.append("")
return "\n".join(lines)
diff --git a/semantica/kg/__init__.py b/semantica/kg/__init__.py
index c31912ef..ceb505a5 100644
--- a/semantica/kg/__init__.py
+++ b/semantica/kg/__init__.py
@@ -126,12 +126,14 @@ from .temporal_query import (
TemporalPatternDetector,
TemporalVersionManager,
)
+from .knowledge_graph import KnowledgeGraph
from .temporal_model import BiTemporalFact, TemporalBound
from .temporal_normalizer import TemporalNormalizer
from .temporal_query_rewriter import TemporalQueryRewriter, TemporalQueryResult
__all__ = [
# Core Classes
+ "KnowledgeGraph",
"GraphBuilder",
"GraphBuilderWithProvenance",
"EntityResolver",
diff --git a/semantica/kg/knowledge_graph.py b/semantica/kg/knowledge_graph.py
new file mode 100644
index 00000000..bfb9a5c0
--- /dev/null
+++ b/semantica/kg/knowledge_graph.py
@@ -0,0 +1,46 @@
+"""
+KnowledgeGraph dataclass — canonical in-memory representation.
+
+This is the formal type produced by the Semantica KG pipeline and consumed
+by visualizers, exporters, and other downstream components. It is a thin,
+immutable-friendly wrapper around three plain collections so that isinstance
+checks, type hints, and IDEs can surface the type rather than relying on
+bare dicts.
+
+Keeping this in its own file avoids circular imports between the kg and
+visualization sub-packages.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List
+
+
+@dataclass
+class KnowledgeGraph:
+ """
+ Canonical in-memory knowledge graph.
+
+ Attributes:
+ entities: List of entity dicts with at minimum ``id`` and ``type`` keys.
+ relationships: List of relationship dicts with at minimum ``source``,
+ ``target``, and ``type`` keys.
+ metadata: Arbitrary graph-level metadata (e.g. build timestamps,
+ entity-resolution flags).
+ """
+
+ entities: List[Dict[str, Any]] = field(default_factory=list)
+ relationships: List[Dict[str, Any]] = field(default_factory=list)
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ # ------------------------------------------------------------------
+ # Convenience helpers
+ # ------------------------------------------------------------------
+
+ def __len__(self) -> int:
+ """Return the number of entities (mirrors the most common 'size' query)."""
+ return len(self.entities)
+
+ def __bool__(self) -> bool:
+ return bool(self.entities or self.relationships)
diff --git a/semantica/kg/path_finder.py b/semantica/kg/path_finder.py
index d8a3e2cc..86269bfb 100644
--- a/semantica/kg/path_finder.py
+++ b/semantica/kg/path_finder.py
@@ -104,7 +104,8 @@ class PathFinder:
source: str,
target: str,
weight_attribute: str = "weight",
- default_weight: float = 1.0
+ default_weight: float = 1.0,
+ directed: bool = True
) -> List[str]:
"""
Find shortest path using Dijkstra's algorithm.
@@ -125,32 +126,34 @@ class PathFinder:
"""
try:
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
-
+
# Validate nodes exist
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
-
+
+ traversal_graph = graph if directed else self._make_undirected_view(graph)
+
# Dijkstra's algorithm
distances = {source: 0.0}
previous = {}
priority_queue = [(0.0, source)]
visited = set()
-
+
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
-
+
if current_node in visited:
continue
-
+
visited.add(current_node)
-
+
if current_node == target:
break
-
+
# Explore neighbors
- for neighbor, edge_data in self._get_neighbors(graph, current_node):
+ for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
if neighbor in visited:
continue
@@ -350,44 +353,48 @@ class PathFinder:
self,
graph: Any,
source: str,
- target: str
+ target: str,
+ directed: bool = True
) -> List[str]:
"""
Find shortest path using BFS (unweighted).
-
+
Args:
graph: Graph object (NetworkX or similar)
source: Source node ID
target: Target node ID
-
+ directed: If False, treat the graph as undirected for traversal
+
Returns:
List of node IDs representing the shortest path
-
+
Raises:
ValueError: If source or target not found
"""
try:
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
-
+
# Validate nodes exist
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
-
+
+ traversal_graph = graph if directed else self._make_undirected_view(graph)
+
# BFS algorithm
queue = deque([(source, [source])])
visited = {source}
-
+
while queue:
current, path = queue.popleft()
-
+
if current == target:
self.logger.info(f"Found BFS path of length {len(path)}")
return path
-
+
# Explore neighbors
- for neighbor, _ in self._get_neighbors(graph, current):
+ for neighbor, _ in self._get_neighbors(traversal_graph, current):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
@@ -564,6 +571,18 @@ class PathFinder:
return False
return False
+ def _make_undirected_view(self, graph: Any) -> Any:
+ """Return an undirected view of the graph for bidirectional traversal.
+
+ For NetworkX directed graphs this calls ``to_undirected()``, which
+ preserves all edge attributes. For graph types that have no such
+ method the original object is returned as a fallback — callers that
+ already expose undirected neighbors will still work correctly.
+ """
+ if hasattr(graph, "to_undirected"):
+ return graph.to_undirected()
+ return graph
+
def _get_neighbors(self, graph: Any, node: str) -> List[Tuple[str, Any]]:
"""Get neighbors of a node with edge data."""
neighbors = []
diff --git a/semantica/mcp_server.py b/semantica/mcp_server.py
new file mode 100644
index 00000000..ee1fb805
--- /dev/null
+++ b/semantica/mcp_server.py
@@ -0,0 +1,606 @@
+"""
+Semantica MCP Server
+
+Exposes Semantica's knowledge graph, decision intelligence, semantic extraction,
+reasoning, and analytics capabilities as an MCP (Model Context Protocol) server
+over stdio — compatible with Claude Desktop, Windsurf, Cline, Continue, VS Code,
+Roo Code, and any other MCP-aware tool.
+
+Usage
+-----
+Configure in your tool's MCP settings:
+
+ Claude Desktop / Windsurf / Cline / Continue / VS Code:
+ {
+ "mcpServers": {
+ "semantica": {
+ "command": "python",
+ "args": ["-m", "semantica.mcp_server"]
+ }
+ }
+ }
+
+Run directly:
+ python -m semantica.mcp_server
+
+Environment variables:
+ SEMANTICA_KG_PATH — path to a persisted graph to load on start (optional)
+ SEMANTICA_LOG_LEVEL — log level: DEBUG, INFO, WARNING (default: WARNING)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+from typing import Any
+
+# ── logging ────────────────────────────────────────────────────────────────
+_log_level = getattr(logging, os.environ.get("SEMANTICA_LOG_LEVEL", "WARNING").upper(), logging.WARNING)
+logging.basicConfig(stream=sys.stderr, level=_log_level,
+ format="%(asctime)s [semantica-mcp] %(levelname)s %(message)s")
+log = logging.getLogger("semantica.mcp_server")
+
+# ── lazy graph session ──────────────────────────────────────────────────────
+_graph: Any = None
+
+
+def _get_graph():
+ global _graph
+ if _graph is None:
+ from semantica.context import ContextGraph
+ _graph = ContextGraph(advanced_analytics=True)
+ kg_path = os.environ.get("SEMANTICA_KG_PATH")
+ if kg_path and os.path.exists(kg_path):
+ try:
+ _graph.load(kg_path)
+ log.info("Loaded graph from %s", kg_path)
+ except Exception as exc:
+ log.warning("Could not load graph from %s: %s", kg_path, exc)
+ return _graph
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Tool implementations
+# ══════════════════════════════════════════════════════════════════════════════
+
+def _tool_extract_entities(args: dict) -> dict:
+ """Extract named entities from text."""
+ text = args.get("text", "")
+ if not text:
+ return {"error": "text is required"}
+ from semantica.semantic_extract import NamedEntityRecognizer
+ from semantica.semantic_extract.cache import _result_cache
+ _result_cache.clear()
+ entities = NamedEntityRecognizer().extract(text)
+ return {
+ "entities": [
+ {"label": getattr(e, "label", str(e)),
+ "type": getattr(e, "type", None),
+ "start": getattr(e, "start", None),
+ "end": getattr(e, "end", None)}
+ for e in (entities or [])
+ ]
+ }
+
+
+def _tool_extract_relations(args: dict) -> dict:
+ """Extract relations and triplets from text."""
+ text = args.get("text", "")
+ if not text:
+ return {"error": "text is required"}
+ from semantica.semantic_extract import RelationExtractor, TripletExtractor
+ from semantica.semantic_extract.cache import _result_cache
+ _result_cache.clear()
+ relations = RelationExtractor().extract(text)
+ triplets = TripletExtractor().extract(text)
+ return {
+ "relations": [
+ {"source": getattr(r, "source", None),
+ "type": getattr(r, "type", None),
+ "target": getattr(r, "target", None)}
+ for r in (relations or [])
+ ],
+ "triplets": [
+ {"subject": getattr(t, "subject", None),
+ "predicate": getattr(t, "predicate", None),
+ "object": getattr(t, "object", None)}
+ for t in (triplets or [])
+ ],
+ }
+
+
+def _tool_record_decision(args: dict) -> dict:
+ """Record a decision with full context into the graph."""
+ required = ["category", "scenario", "reasoning", "outcome", "confidence"]
+ for field in required:
+ if field not in args:
+ return {"error": f"missing required field: {field}"}
+ graph = _get_graph()
+ decision_id = graph.record_decision(
+ category=args["category"],
+ scenario=args["scenario"],
+ reasoning=args["reasoning"],
+ outcome=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"}
+
+
+def _tool_query_decisions(args: dict) -> dict:
+ """Query decisions by natural language or structured filters."""
+ query = args.get("query", "")
+ category = args.get("category")
+ limit = int(args.get("limit", 10))
+ graph = _get_graph()
+ try:
+ if query:
+ results = graph.find_similar_decisions(query, max_results=limit)
+ elif category:
+ nodes = graph.find_nodes(node_type="decision")
+ results = [n for n in nodes if n.get("category") == category][:limit]
+ else:
+ results = graph.find_nodes(node_type="decision")[:limit]
+ return {"decisions": results if isinstance(results, list) else list(results)}
+ except Exception as exc:
+ return {"error": str(exc), "decisions": []}
+
+
+def _tool_find_precedents(args: dict) -> dict:
+ """Find past decisions similar to a given scenario."""
+ scenario = args.get("scenario", "")
+ if not scenario:
+ return {"error": "scenario is required"}
+ max_results = int(args.get("max_results", 5))
+ graph = _get_graph()
+ try:
+ precedents = graph.find_similar_decisions(scenario, max_results=max_results)
+ return {"precedents": precedents if isinstance(precedents, list) else list(precedents)}
+ except Exception as exc:
+ return {"error": str(exc), "precedents": []}
+
+
+def _tool_get_causal_chain(args: dict) -> dict:
+ """Get the causal chain for a decision."""
+ decision_id = args.get("decision_id", "")
+ if not decision_id:
+ return {"error": "decision_id is required"}
+ direction = args.get("direction", "downstream")
+ max_depth = int(args.get("max_depth", 5))
+ 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)
+ return {"chain": chain if isinstance(chain, list) else list(chain)}
+ except Exception as exc:
+ return {"error": str(exc), "chain": []}
+
+
+def _tool_add_entity(args: dict) -> dict:
+ """Add a node/entity to the knowledge graph."""
+ node_id = args.get("id", "")
+ label = args.get("label", node_id)
+ node_type = args.get("type", "Entity")
+ if not node_id:
+ return {"error": "id is required"}
+ graph = _get_graph()
+ graph.add_node(node_id=node_id, label=label, node_type=node_type,
+ metadata=args.get("metadata", {}))
+ return {"status": "added", "id": node_id}
+
+
+def _tool_add_relationship(args: dict) -> dict:
+ """Add a relationship (edge) between two entities."""
+ source = args.get("source", "")
+ target = args.get("target", "")
+ rel_type = args.get("type", "RELATED_TO")
+ if not source or not target:
+ return {"error": "source and target are required"}
+ graph = _get_graph()
+ graph.add_edge(source_id=source, target_id=target, edge_type=rel_type,
+ metadata=args.get("metadata", {}))
+ return {"status": "added", "source": source, "target": target, "type": rel_type}
+
+
+def _tool_run_reasoning(args: dict) -> dict:
+ """Run forward-chaining reasoning rules over a set of facts."""
+ facts = args.get("facts", [])
+ rules = args.get("rules", [])
+ if not facts or not rules:
+ return {"error": "facts and rules are required"}
+ from semantica.reasoning import Reasoner
+ reasoner = Reasoner()
+ for rule in rules:
+ reasoner.add_rule(rule)
+ derived = reasoner.infer_facts(facts)
+ return {"derived_facts": derived if isinstance(derived, list) else list(derived)}
+
+
+def _tool_get_graph_analytics(args: dict) -> dict:
+ """Compute graph analytics: centrality, community detection, metrics."""
+ graph = _get_graph()
+ try:
+ from semantica.kg import CentralityCalculator, CommunityDetector
+ centrality = CentralityCalculator().calculate_pagerank(graph)
+ communities = CommunityDetector().detect_communities(graph)
+ node_count = len(list(graph.find_nodes()))
+ edge_count = getattr(graph, "edge_count", lambda: 0)()
+ return {
+ "node_count": node_count,
+ "edge_count": edge_count,
+ "top_nodes_by_pagerank": sorted(
+ centrality.items() if hasattr(centrality, "items") else [],
+ key=lambda x: x[1], reverse=True
+ )[:10],
+ "community_count": len(communities) if isinstance(communities, (list, dict)) else 0,
+ }
+ except Exception as exc:
+ return {"error": str(exc)}
+
+
+def _tool_export_graph(args: dict) -> dict:
+ """Export the current knowledge graph to a serialised format."""
+ fmt = args.get("format", "json-ld")
+ graph = _get_graph()
+ try:
+ from semantica.export import RDFExporter, JSONExporter
+ if fmt in ("turtle", "ttl", "nt", "xml", "json-ld"):
+ result = RDFExporter().export_to_rdf(graph, format=fmt)
+ else:
+ result = JSONExporter().export(graph)
+ return {"format": fmt, "data": result}
+ except Exception as exc:
+ return {"error": str(exc)}
+
+
+def _tool_get_graph_summary(args: dict) -> dict:
+ """Return a high-level summary of the current graph."""
+ graph = _get_graph()
+ try:
+ node_count = len(list(graph.find_nodes()))
+ decisions = graph.find_nodes(node_type="decision")
+ return {
+ "node_count": node_count,
+ "decision_count": len(list(decisions)),
+ "graph_ready": True,
+ }
+ except Exception as exc:
+ return {"error": str(exc), "graph_ready": False}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# MCP protocol tables
+# ══════════════════════════════════════════════════════════════════════════════
+
+TOOLS = [
+ {
+ "name": "extract_entities",
+ "description": "Extract named entities (people, places, organisations, concepts) from text using Semantica NER.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string", "description": "Input text to extract entities from"}
+ },
+ "required": ["text"],
+ },
+ "_handler": _tool_extract_entities,
+ },
+ {
+ "name": "extract_relations",
+ "description": "Extract relations and (subject, predicate, object) triplets from text.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string", "description": "Input text to extract relations from"}
+ },
+ "required": ["text"],
+ },
+ "_handler": _tool_extract_relations,
+ },
+ {
+ "name": "record_decision",
+ "description": "Record a decision into the Semantica knowledge graph with full context, causal links, and metadata.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "category": {"type": "string", "description": "Decision category, e.g. 'loan_approval'"},
+ "scenario": {"type": "string", "description": "Natural-language situation description"},
+ "reasoning": {"type": "string", "description": "Why this decision was made"},
+ "outcome": {"type": "string", "description": "Decision outcome, e.g. 'approved'"},
+ "confidence": {"type": "number", "description": "Confidence score 0–1"},
+ "decision_maker":{"type": "string", "description": "Who/what made the decision"},
+ "valid_from": {"type": "string", "description": "ISO date validity start (optional)"},
+ "valid_until": {"type": "string", "description": "ISO date validity end (optional)"},
+ },
+ "required": ["category", "scenario", "reasoning", "outcome", "confidence"],
+ },
+ "_handler": _tool_record_decision,
+ },
+ {
+ "name": "query_decisions",
+ "description": "Query recorded decisions by natural language, category, or get all recent decisions.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Natural language query (optional)"},
+ "category": {"type": "string", "description": "Filter by category (optional)"},
+ "limit": {"type": "integer", "description": "Max results (default 10)"},
+ },
+ },
+ "_handler": _tool_query_decisions,
+ },
+ {
+ "name": "find_precedents",
+ "description": "Find past decisions similar to a given scenario using hybrid similarity search.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "scenario": {"type": "string", "description": "Scenario description to find precedents for"},
+ "max_results": {"type": "integer", "description": "Max results (default 5)"},
+ },
+ "required": ["scenario"],
+ },
+ "_handler": _tool_find_precedents,
+ },
+ {
+ "name": "get_causal_chain",
+ "description": "Trace the causal chain upstream or downstream from a decision.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "decision_id": {"type": "string", "description": "Decision ID to trace"},
+ "direction": {"type": "string", "enum": ["upstream", "downstream"], "description": "Trace direction"},
+ "max_depth": {"type": "integer", "description": "Max chain depth (default 5)"},
+ },
+ "required": ["decision_id"],
+ },
+ "_handler": _tool_get_causal_chain,
+ },
+ {
+ "name": "add_entity",
+ "description": "Add a node/entity to the Semantica knowledge graph.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "id": {"type": "string", "description": "Unique node ID"},
+ "label": {"type": "string", "description": "Human-readable label"},
+ "type": {"type": "string", "description": "Node type, e.g. 'Person', 'Organisation'"},
+ "metadata": {"type": "object", "description": "Additional properties"},
+ },
+ "required": ["id"],
+ },
+ "_handler": _tool_add_entity,
+ },
+ {
+ "name": "add_relationship",
+ "description": "Add a directed relationship (edge) between two entities in the knowledge graph.",
+ "inputSchema": {
+ "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'"},
+ "metadata": {"type": "object", "description": "Additional edge properties"},
+ },
+ "required": ["source", "target"],
+ },
+ "_handler": _tool_add_relationship,
+ },
+ {
+ "name": "run_reasoning",
+ "description": "Run forward-chaining IF/THEN rules over a set of facts to derive new facts.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "facts": {
+ "type": "array", "items": {"type": "string"},
+ "description": "List of 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 WorkerBee(?x)']",
+ },
+ },
+ "required": ["facts", "rules"],
+ },
+ "_handler": _tool_run_reasoning,
+ },
+ {
+ "name": "get_graph_analytics",
+ "description": "Compute PageRank centrality and community detection over the knowledge graph.",
+ "inputSchema": {"type": "object", "properties": {}},
+ "_handler": _tool_get_graph_analytics,
+ },
+ {
+ "name": "export_graph",
+ "description": "Export the current knowledge graph. Formats: turtle, ttl, nt, xml, json-ld, json.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "format": {
+ "type": "string",
+ "enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json"],
+ "description": "Export format (default: json-ld)",
+ }
+ },
+ },
+ "_handler": _tool_export_graph,
+ },
+ {
+ "name": "get_graph_summary",
+ "description": "Return a high-level summary of the current knowledge graph: node count, decision count, status.",
+ "inputSchema": {"type": "object", "properties": {}},
+ "_handler": _tool_get_graph_summary,
+ },
+]
+
+RESOURCES = [
+ {
+ "uri": "semantica://graph/summary",
+ "name": "Graph Summary",
+ "description": "High-level statistics about the current knowledge graph",
+ "mimeType": "application/json",
+ },
+ {
+ "uri": "semantica://decisions/list",
+ "name": "Decisions",
+ "description": "List of all recorded decisions in the graph",
+ "mimeType": "application/json",
+ },
+ {
+ "uri": "semantica://schema/info",
+ "name": "Schema Info",
+ "description": "Semantica server info and available capabilities",
+ "mimeType": "application/json",
+ },
+]
+
+
+def _read_resource(uri: str) -> dict:
+ if uri == "semantica://graph/summary":
+ return _tool_get_graph_summary({})
+ if uri == "semantica://decisions/list":
+ return _tool_query_decisions({"limit": 50})
+ if uri == "semantica://schema/info":
+ return {
+ "name": "Semantica",
+ "version": "0.4.0",
+ "tools": [t["name"] for t in TOOLS],
+ "resources": [r["uri"] for r in RESOURCES],
+ }
+ return {"error": f"Unknown resource URI: {uri}"}
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# JSON-RPC / MCP protocol handler
+# ══════════════════════════════════════════════════════════════════════════════
+
+SERVER_INFO = {
+ "name": "semantica",
+ "version": "0.4.0",
+}
+
+CAPABILITIES = {
+ "tools": {"listChanged": False},
+ "resources": {"listChanged": False, "subscribe": False},
+}
+
+
+def _handle(req: dict) -> dict | None:
+ """Dispatch a single JSON-RPC request; return None for notifications."""
+ method = req.get("method", "")
+ params = req.get("params") or {}
+ req_id = req.get("id")
+
+ def ok(result):
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
+
+ def err(code, message):
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
+
+ # Notifications (no id) — acknowledge silently
+ if req_id is None and method.startswith("notifications/"):
+ return None
+
+ if method == "initialize":
+ return ok({
+ "protocolVersion": "2024-11-05",
+ "capabilities": CAPABILITIES,
+ "serverInfo": SERVER_INFO,
+ })
+
+ if method == "notifications/initialized":
+ return None
+
+ if method == "ping":
+ return ok({})
+
+ if method == "tools/list":
+ tools_out = [
+ {"name": t["name"], "description": t["description"], "inputSchema": t["inputSchema"]}
+ for t in TOOLS
+ ]
+ return ok({"tools": tools_out})
+
+ if method == "tools/call":
+ name = params.get("name", "")
+ arguments = params.get("arguments") or {}
+ handler = next((t["_handler"] for t in TOOLS if t["name"] == name), None)
+ if handler is None:
+ return err(-32601, f"Unknown tool: {name}")
+ try:
+ result = handler(arguments)
+ text = json.dumps(result, ensure_ascii=False, indent=2)
+ return ok({"content": [{"type": "text", "text": text}]})
+ except Exception as exc:
+ log.exception("Tool %s raised", name)
+ return err(-32603, str(exc))
+
+ if method == "resources/list":
+ return ok({"resources": RESOURCES})
+
+ if method == "resources/read":
+ uri = params.get("uri", "")
+ data = _read_resource(uri)
+ text = json.dumps(data, ensure_ascii=False, indent=2)
+ return ok({"contents": [{"uri": uri, "mimeType": "application/json", "text": text}]})
+
+ if method == "prompts/list":
+ return ok({"prompts": []})
+
+ return err(-32601, f"Method not found: {method}")
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# stdio event loop
+# ══════════════════════════════════════════════════════════════════════════════
+
+def _run_stdio():
+ log.info("Semantica MCP server starting on stdio")
+ # Use binary stdin/stdout for reliable newline handling on Windows
+ stdin = sys.stdin.buffer
+ stdout = sys.stdout.buffer
+
+ while True:
+ try:
+ line = stdin.readline()
+ if not line:
+ break
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ req = json.loads(line)
+ except json.JSONDecodeError as exc:
+ resp = {"jsonrpc": "2.0", "id": None,
+ "error": {"code": -32700, "message": f"Parse error: {exc}"}}
+ stdout.write(json.dumps(resp).encode() + b"\n")
+ stdout.flush()
+ continue
+
+ resp = _handle(req)
+ if resp is not None:
+ stdout.write(json.dumps(resp, ensure_ascii=False).encode() + b"\n")
+ stdout.flush()
+ except EOFError:
+ break
+ except KeyboardInterrupt:
+ break
+ except Exception as exc:
+ log.exception("Unhandled error in MCP loop: %s", exc)
+
+ log.info("Semantica MCP server stopped")
+
+
+def main():
+ _run_stdio()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/semantica/semantic_extract/providers.py b/semantica/semantic_extract/providers.py
index 9a43cf58..a0e4979b 100644
--- a/semantica/semantic_extract/providers.py
+++ b/semantica/semantic_extract/providers.py
@@ -397,6 +397,7 @@ class BaseProvider:
create_kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**create_kwargs)
+ verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
if verbose_mode:
import sys
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
@@ -939,20 +940,22 @@ class DeepSeekProvider(BaseProvider):
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
super().__init__(**kwargs)
self.api_key = api_key or config.get_api_key("deepseek")
+ self.base_url = "https://api.deepseek.com/v1"
self.model = model
+ self.base_url = "https://api.deepseek.com/v1"
self.client = None
self._init_client()
def _init_client(self):
try:
- import deepseek # type: ignore[import-untyped]
+ from openai import OpenAI
if self.api_key:
- self.client = deepseek.Client(api_key=self.api_key)
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
except (ImportError, OSError):
self.client = None
self.logger.warning(
- "deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
+ "openai library not installed. Install with: pip install semantica[llm-openai]"
)
def is_available(self) -> bool:
diff --git a/semantica/utils/helpers.py b/semantica/utils/helpers.py
index 3a23f81b..21f6cfc2 100644
--- a/semantica/utils/helpers.py
+++ b/semantica/utils/helpers.py
@@ -562,3 +562,25 @@ def retry_on_error(
return wrapper
return decorator
+
+
+def classify_path_distance(hop_count: int) -> str:
+ """Classify a path hop count into a human-readable distance band.
+
+ Bands:
+ "direct" — 0–1 hops (single edge or self)
+ "near" — 2–3 hops (closely related)
+ "mid-range" — 4–6 hops (reachable but separated)
+ "distant" — 7+ hops (weakly coupled)
+
+ This is the single source of truth for distance-band thresholds used by
+ both the Explorer API (PathResponse.distance_band) and the KGVisualizer
+ (highlight_path edge styling).
+ """
+ if hop_count <= 1:
+ return "direct"
+ if hop_count <= 3:
+ return "near"
+ if hop_count <= 6:
+ return "mid-range"
+ return "distant"
diff --git a/semantica/visualization/kg_visualizer.py b/semantica/visualization/kg_visualizer.py
index a6187f7a..b265df70 100644
--- a/semantica/visualization/kg_visualizer.py
+++ b/semantica/visualization/kg_visualizer.py
@@ -53,6 +53,15 @@ except (ImportError, OSError):
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
+
+# Optional import — keeps the visualizer usable even if the kg sub-package
+# is not installed, and avoids circular-import risk at module level.
+try:
+ from ..kg.knowledge_graph import KnowledgeGraph as _KnowledgeGraph
+except Exception: # pragma: no cover
+ _KnowledgeGraph = None # type: ignore[assignment,misc]
+
+from ..utils.helpers import classify_path_distance
from ..utils.progress_tracker import get_progress_tracker
from .utils.color_schemes import ColorPalette, ColorScheme
from .utils.export_formats import (
@@ -118,14 +127,72 @@ class KGVisualizer:
"Install with: pip install plotly"
)
+ def _convert_knowledge_graph(self, kg: Any) -> Dict[str, Any]:
+ """
+ Convert a KnowledgeGraph instance to the internal dict format.
+
+ Non-mutating. Preserves node types, labels, properties, edge types,
+ weights, and direction.
+
+ Args:
+ kg: A ``KnowledgeGraph`` instance.
+
+ Returns:
+ Dict with "entities", "relationships", and "metadata" keys.
+ """
+ entities = getattr(kg, "entities", None) or []
+ relationships = getattr(kg, "relationships", None) or []
+ metadata = getattr(kg, "metadata", None) or {}
+ return {
+ "entities": list(entities),
+ "relationships": list(relationships),
+ "metadata": dict(metadata),
+ }
+
+ def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
+ """
+ Normalize graph input to the expected dict format.
+
+ Accepts:
+ - A ``KnowledgeGraph`` instance (routed through ``_convert_knowledge_graph``)
+ - A dict with "entities" and "relationships" keys (canonical format)
+ - Any object that exposes .entities and .relationships attributes
+ (duck-typed, e.g. custom dataclasses)
+
+ Returns:
+ Dict with "entities", "relationships", and "metadata" keys.
+ """
+ # Explicit fast-path for the formal KnowledgeGraph type
+ if _KnowledgeGraph is not None and isinstance(graph, _KnowledgeGraph):
+ return self._convert_knowledge_graph(graph)
+
+ if isinstance(graph, dict):
+ return graph
+
+ # Duck-type: accept any object with .entities / .relationships
+ entities = getattr(graph, "entities", None)
+ relationships = getattr(graph, "relationships", None)
+ if entities is None and relationships is None:
+ raise ProcessingError(
+ f"Cannot visualize object of type '{type(graph).__name__}': "
+ "expected a dict with 'entities'/'relationships' keys, or an object "
+ "with .entities and .relationships attributes."
+ )
+ return {
+ "entities": list(entities) if entities is not None else [],
+ "relationships": list(relationships) if relationships is not None else [],
+ "metadata": dict(getattr(graph, "metadata", None) or {}),
+ }
+
def visualize_network(
self,
- graph: Dict[str, Any],
+ graph: Union[Dict[str, Any], Any],
output: str = "interactive",
file_path: Optional[Union[str, Path]] = None,
node_color_by: str = "type",
node_size_by: Optional[str] = None,
hover_data: Optional[List[str]] = None,
+ highlight_path: Optional[List[str]] = None,
**options,
) -> Optional[Any]:
"""
@@ -139,18 +206,24 @@ class KGVisualizer:
5. Interaction: rich hover data and zoom capabilities
Args:
- graph: Knowledge graph dictionary with entities and relationships
+ graph: Knowledge graph — either a dict with "entities"/"relationships"
+ keys, or any object exposing .entities and .relationships attributes
+ (e.g. the result of GraphBuilder.build())
output: Output type ("interactive", "html", "png", "svg")
file_path: Output file path (required for non-interactive)
node_color_by: Property to map to node color (default: "type")
node_size_by: Property to map to node size (default: fixed)
hover_data: List of properties to show in hover tooltip
+ highlight_path: Optional ordered list of node IDs forming a path to
+ highlight with distance-aware edge styling (opacity and stroke
+ weight reflect hop count along the path).
**options: Additional visualization options
Returns:
Plotly figure (if interactive) or None
"""
self._check_dependencies()
+ graph = self._normalize_graph(graph)
tracking_id = self.progress_tracker.start_tracking(
module="visualization",
submodule="KGVisualizer",
@@ -193,13 +266,14 @@ class KGVisualizer:
tracking_id, message="Generating visualization..."
)
result = self._visualize_network_plotly(
- nodes,
- edges,
- output,
- file_path,
+ nodes,
+ edges,
+ output,
+ file_path,
node_color_by=node_color_by,
node_size_by=node_size_by,
hover_data=hover_data,
+ highlight_path=highlight_path,
**options
)
@@ -237,6 +311,7 @@ class KGVisualizer:
Visualization figure or None
"""
self._check_dependencies()
+ graph = self._normalize_graph(graph)
self.logger.info("Visualizing knowledge graph communities")
entities = graph.get("entities", [])
@@ -296,6 +371,7 @@ class KGVisualizer:
Visualization figure or None
"""
self._check_dependencies()
+ graph = self._normalize_graph(graph)
self.logger.info(
f"Visualizing knowledge graph with {centrality_type} centrality"
)
@@ -350,6 +426,7 @@ class KGVisualizer:
Visualization figure or None
"""
self._check_dependencies()
+ graph = self._normalize_graph(graph)
self.logger.info("Visualizing entity type distribution")
entities = graph.get("entities", [])
@@ -395,6 +472,7 @@ class KGVisualizer:
Visualization figure or None
"""
self._check_dependencies()
+ graph = self._normalize_graph(graph)
self.logger.info("Visualizing relationship matrix")
entities = graph.get("entities", [])
@@ -492,6 +570,21 @@ class KGVisualizer:
return edges
+ @staticmethod
+ def _path_edge_style(distance_band: str) -> Tuple[float, float]:
+ """Return (opacity, width) for a path edge based on its distance band.
+
+ Bands come from ``classify_path_distance`` in ``utils.helpers`` — the
+ single source of truth for hop-count thresholds.
+ """
+ if distance_band == "direct":
+ return (1.0, 4.0)
+ if distance_band == "near":
+ return (0.85, 3.0)
+ if distance_band == "mid-range":
+ return (0.6, 2.0)
+ return (0.35, 1.5) # "distant"
+
def _visualize_network_plotly(
self,
nodes: List[Dict[str, Any]],
@@ -501,6 +594,7 @@ class KGVisualizer:
node_color_by: str = "type",
node_size_by: Optional[str] = None,
hover_data: Optional[List[str]] = None,
+ highlight_path: Optional[List[str]] = None,
**options,
) -> Optional[Any]:
"""Create Plotly network visualization."""
@@ -603,47 +697,73 @@ class KGVisualizer:
node_text.append(text)
- # Prepare edge traces
- edge_x = []
- edge_y = []
-
+ # Build path edge lookup for highlight_path support
+ path_edge_set: set = set()
+ path_distance_band = "direct"
+ if highlight_path and len(highlight_path) >= 2:
+ path_hop_count = len(highlight_path) - 1
+ path_distance_band = classify_path_distance(path_hop_count)
+ # Only add the directed edges that actually form the path (A→B, not B→A).
+ # Adding the reverse would incorrectly highlight unrelated back-edges.
+ for i in range(path_hop_count):
+ path_edge_set.add((highlight_path[i], highlight_path[i + 1]))
+ # Warn if any path node has no layout position (silent highlight failure).
+ missing = [n for n in highlight_path if n not in pos]
+ if missing:
+ self.logger.warning(
+ "highlight_path contains node IDs not found in the graph: %s",
+ missing,
+ )
+
+ path_opacity, path_width = self._path_edge_style(path_distance_band)
+
+ # Prepare edge traces — split into background (non-path) and path edges
+ edge_x: List = []
+ edge_y: List = []
+ path_edge_x: List = []
+ path_edge_y: List = []
+
# Prepare edge label traces and annotations (for arrows)
edge_label_x = []
edge_label_y = []
edge_label_text = []
annotations = []
-
+
# Limit detailed edge rendering for performance if graph is too large
show_detailed_edges = len(edges) < 500
-
+
for edge in edges:
source_pos = pos.get(edge["source"])
target_pos = pos.get(edge["target"])
if source_pos and target_pos:
x0, y0 = source_pos
x1, y1 = target_pos
- edge_x.extend([x0, x1, None])
- edge_y.extend([y0, y1, None])
-
+
+ is_path_edge = (edge["source"], edge["target"]) in path_edge_set
+ if is_path_edge:
+ path_edge_x.extend([x0, x1, None])
+ path_edge_y.extend([y0, y1, None])
+ else:
+ edge_x.extend([x0, x1, None])
+ edge_y.extend([y0, y1, None])
+
if show_detailed_edges:
# Calculate midpoint for label
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
-
+
if edge.get("label"):
edge_label_x.append(mx)
edge_label_y.append(my)
edge_label_text.append(edge["label"])
-
+
# Add arrow annotation
- # Adjust arrow to point slightly before the node to avoid overlap with node marker
- # This is approximate; precise calculation requires node size
annotations.append(
dict(
ax=x0, ay=y0, axref='x', ayref='y',
x=x1, y=y1, xref='x', yref='y',
arrowhead=2, arrowsize=1, arrowwidth=1,
arrowcolor="#888", opacity=0.6,
- standoff=15 # Distance from target node
+ standoff=15
)
)
@@ -656,9 +776,22 @@ class KGVisualizer:
showlegend=False,
opacity=0.5
)
-
+
traces = [edge_trace]
-
+
+ # Overlay highlighted path edges with distance-aware styling
+ if path_edge_x:
+ path_trace = go.Scatter(
+ x=path_edge_x,
+ y=path_edge_y,
+ line=dict(width=path_width, color="#e05c00"),
+ hoverinfo="none",
+ mode="lines",
+ showlegend=False,
+ opacity=path_opacity,
+ )
+ traces.append(path_trace)
+
if show_detailed_edges and edge_label_text:
edge_label_trace = go.Scatter(
x=edge_label_x,
diff --git a/tests/explorer/test_explorer_api.py b/tests/explorer/test_explorer_api.py
index f0d56859..d420fcb7 100644
--- a/tests/explorer/test_explorer_api.py
+++ b/tests/explorer/test_explorer_api.py
@@ -4,6 +4,7 @@ import json
from pathlib import Path
import uuid
+import networkx as nx
import pytest
from semantica.context.context_graph import ContextGraph
@@ -35,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30)
graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60)
+ graph.add_node(
+ "metformin",
+ node_type="drug",
+ content="Metformin",
+ aliases=["Glucophage"],
+ confidence="0.97",
+ tags=["drug", "featured"],
+ x=22,
+ y=33,
+ )
graph.add_node(
"decision_1",
node_type="decision",
@@ -243,6 +254,74 @@ class TestSearchAndStats:
assert payload["total"] >= 1
assert all(item["node"]["type"] == "language" for item in payload["results"])
+ def test_search_exact_and_prefix(self, client):
+ exact_response = client.post(
+ "/api/graph/search",
+ json={"query": "Metformin", "limit": 5},
+ )
+ assert exact_response.status_code == 200
+ exact_payload = exact_response.json()
+ assert exact_payload["results"][0]["node"]["id"] == "metformin"
+
+ prefix_response = client.post(
+ "/api/graph/search",
+ json={"query": "metf", "limit": 5},
+ )
+ assert prefix_response.status_code == 200
+ prefix_payload = prefix_response.json()
+ assert any(item["node"]["id"] == "metformin" for item in prefix_payload["results"])
+
+ def test_search_filters_and_cache_stability(self, client):
+ body = {
+ "query": "framework",
+ "filters": {"type": "decision", "min_confidence": 0.8},
+ "limit": 5,
+ }
+ first_response = client.post("/api/graph/search", json=body)
+ second_response = client.post("/api/graph/search", json=body)
+
+ assert first_response.status_code == 200
+ assert second_response.status_code == 200
+ assert first_response.json() == second_response.json()
+ results = first_response.json()["results"]
+ assert [item["node"]["id"] for item in results] == ["decision_1"]
+
+ def test_search_sees_new_nodes_after_mutation(self, client):
+ session = client.app.state.session
+ assert session.add_node(
+ "metformin_hcl",
+ "drug",
+ content="Metformin Hydrochloride",
+ aliases=["Glucophage XR"],
+ confidence="0.93",
+ )
+
+ response = client.post(
+ "/api/graph/search",
+ json={"query": "glucophage", "limit": 10},
+ )
+ assert response.status_code == 200
+ result_ids = [item["node"]["id"] for item in response.json()["results"]]
+ assert "metformin" in result_ids
+ assert "metformin_hcl" in result_ids
+
+ def test_search_secondary_scan_fallback_matches_non_curated_properties(self, client):
+ session = client.app.state.session
+ assert session.add_node(
+ "fallback_node",
+ "entity",
+ content="Alpha",
+ description="rareterm",
+ )
+
+ response = client.post(
+ "/api/graph/search",
+ json={"query": "rareterm", "limit": 10},
+ )
+ assert response.status_code == 200
+ result_ids = [item["node"]["id"] for item in response.json()["results"]]
+ assert "fallback_node" in result_ids
+
def test_stats(self, client):
response = client.get("/api/graph/stats")
assert response.status_code == 200
@@ -372,7 +451,7 @@ class TestEnrichment:
def test_extract(self, client):
response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."})
- assert response.status_code in (200, 422)
+ assert response.status_code in (200, 422, 503)
def test_link_prediction(self, client):
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
@@ -638,3 +717,177 @@ class TestGenericGraphFileLoading:
assert repeat.status_code == 200
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
assert repeat_ids == ["edge-alpha", "edge-beta"]
+
+
+# ---------------------------------------------------------------------------
+# Bidirectional path-finding tests (issue #469)
+# ---------------------------------------------------------------------------
+
+def _make_path_session() -> GraphSession:
+ """Return a GraphSession whose build_graph_dict yields an nx.DiGraph with A→B only.
+
+ GraphSession wraps a ContextGraph (required by create_app), but we patch
+ build_graph_dict so PathFinder receives an actual NetworkX DiGraph — the
+ graph type the Explorer is designed to traverse for path queries.
+ """
+ cg = ContextGraph(advanced_analytics=False)
+ cg.add_node("A", node_type="entity", content="Node A")
+ cg.add_node("B", node_type="entity", content="Node B")
+ cg.add_edge("A", "B", edge_type="connects")
+
+ session = GraphSession(cg)
+
+ # Patch build_graph_dict to return the directed NetworkX graph that
+ # PathFinder needs. The ContextGraph dict format is not traversable by
+ # PathFinder; this mimics how a KG-backed session would expose the graph.
+ digraph = nx.DiGraph()
+ digraph.add_edge("A", "B")
+ session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
+
+ return session
+
+
+@pytest.fixture
+def path_client():
+ session = _make_path_session()
+ app = create_app(session=session)
+ with TestClient(app) as c:
+ yield c
+
+
+class TestBidirectionalPathRoute:
+ """API-level tests for directed=true/false on GET /api/graph/node/{id}/path."""
+
+ # ------------------------------------------------------------------
+ # directed=true (default) — existing directed-only behaviour
+ # ------------------------------------------------------------------
+
+ def test_directed_true_forward_path_found(self, path_client):
+ """A→B exists: forward query with directed=true must succeed."""
+ resp = path_client.get("/api/graph/node/A/path?target=B&directed=true")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["path"] == ["A", "B"]
+ assert body["directed"] is True
+
+ def test_directed_true_reverse_returns_404(self, path_client):
+ """Only A→B exists: reverse query with directed=true must return 404."""
+ resp = path_client.get("/api/graph/node/B/path?target=A&directed=true")
+ assert resp.status_code == 404
+
+ def test_default_param_reverse_returns_404(self, path_client):
+ """Omitting directed= must preserve current directed behaviour (404 for reverse)."""
+ resp = path_client.get("/api/graph/node/B/path?target=A")
+ assert resp.status_code == 404
+
+ # ------------------------------------------------------------------
+ # directed=false — new undirected traversal
+ # ------------------------------------------------------------------
+
+ def test_directed_false_reverse_path_found(self, path_client):
+ """directed=false must find B→A even though only A→B exists."""
+ resp = path_client.get("/api/graph/node/B/path?target=A&directed=false")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["path"] == ["B", "A"]
+ assert body["directed"] is False
+
+ def test_directed_false_forward_path_found(self, path_client):
+ """directed=false must not break the natural A→B direction."""
+ resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["path"] == ["A", "B"]
+ assert body["directed"] is False
+
+ # ------------------------------------------------------------------
+ # Algorithm variants
+ # ------------------------------------------------------------------
+
+ def test_dijkstra_directed_false_reverse(self, path_client):
+ resp = path_client.get(
+ "/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=false"
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["path"] == ["B", "A"]
+ assert body["algorithm"] == "dijkstra"
+ assert body["directed"] is False
+
+ def test_dijkstra_directed_true_reverse_returns_404(self, path_client):
+ resp = path_client.get(
+ "/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=true"
+ )
+ assert resp.status_code == 404
+
+ # ------------------------------------------------------------------
+ # PathResponse schema
+ # ------------------------------------------------------------------
+
+ def test_response_schema_includes_directed_field(self, path_client):
+ """PathResponse must always include the directed field."""
+ resp = path_client.get("/api/graph/node/A/path?target=B")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "directed" in body
+
+ def test_response_directed_reflects_query_param(self, path_client):
+ resp_true = path_client.get("/api/graph/node/A/path?target=B&directed=true")
+ resp_false = path_client.get("/api/graph/node/A/path?target=B&directed=false")
+ assert resp_true.json()["directed"] is True
+ assert resp_false.json()["directed"] is False
+
+ # ------------------------------------------------------------------
+ # hop_count and distance_band — issue #472
+ # ------------------------------------------------------------------
+
+ def test_response_includes_hop_count_and_distance_band(self, path_client):
+ """PathResponse must include hop_count and distance_band fields."""
+ resp = path_client.get("/api/graph/node/A/path?target=B")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "hop_count" in body
+ assert "distance_band" in body
+
+ def test_one_hop_path_is_direct(self, path_client):
+ """A single-edge path (1 hop) must return distance_band='direct'."""
+ resp = path_client.get("/api/graph/node/A/path?target=B")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["hop_count"] == 1
+ assert body["distance_band"] == "direct"
+
+
+# ---------------------------------------------------------------------------
+# _classify_distance unit tests — issue #472
+# ---------------------------------------------------------------------------
+
+from semantica.utils.helpers import classify_path_distance
+
+
+class TestClassifyDistance:
+ """Unit tests covering all four band boundaries."""
+
+ def test_zero_hops_is_direct(self):
+ assert classify_path_distance(0) == "direct"
+
+ def test_one_hop_is_direct(self):
+ assert classify_path_distance(1) == "direct"
+
+ def test_two_hops_is_near(self):
+ assert classify_path_distance(2) == "near"
+
+ def test_three_hops_is_near(self):
+ assert classify_path_distance(3) == "near"
+
+ def test_four_hops_is_mid_range(self):
+ assert classify_path_distance(4) == "mid-range"
+
+ def test_six_hops_is_mid_range(self):
+ assert classify_path_distance(6) == "mid-range"
+
+ def test_seven_hops_is_distant(self):
+ assert classify_path_distance(7) == "distant"
+
+ def test_large_hop_count_is_distant(self):
+ assert classify_path_distance(20) == "distant"
diff --git a/tests/explorer/test_provenance_route.py b/tests/explorer/test_provenance_route.py
new file mode 100644
index 00000000..73f59bf2
--- /dev/null
+++ b/tests/explorer/test_provenance_route.py
@@ -0,0 +1,74 @@
+"""Unit tests for explorer provenance route helpers."""
+
+from types import SimpleNamespace
+
+from semantica.explorer.routes.provenance import _build_provenance, _render_markdown
+
+
+def _make_session_with_chain() -> SimpleNamespace:
+ """Build a minimal session-like object for Source -> Intermediate -> node_id."""
+ nodes = {
+ "Source": SimpleNamespace(node_type="entity", content="Source"),
+ "Intermediate": SimpleNamespace(node_type="entity", content="Intermediate"),
+ "node_id": SimpleNamespace(node_type="entity", content="Target"),
+ }
+ edges = [
+ SimpleNamespace(source_id="Source", target_id="Intermediate", edge_type="related_to"),
+ SimpleNamespace(source_id="Intermediate", target_id="node_id", edge_type="related_to"),
+ ]
+ graph = SimpleNamespace(nodes=nodes, edges=edges)
+ return SimpleNamespace(graph=graph)
+
+
+def test_build_provenance_direction_classification_chain():
+ session = _make_session_with_chain()
+
+ data = _build_provenance(session, "node_id")
+
+ node_ids = {node["id"] for node in data["nodes"]}
+ assert "Source" in node_ids
+ assert "Intermediate" in node_ids
+
+ edge_by_pair = {(edge["source"], edge["target"]): edge for edge in data["edges"]}
+
+ assert edge_by_pair[("Intermediate", "node_id")]["direction"] == "upstream"
+ assert edge_by_pair[("Source", "Intermediate")]["direction"] != "downstream"
+
+
+def test_render_markdown_groups_edges_by_direction():
+ report = {
+ "node_id": "node_id",
+ "label": "Target",
+ "type": "entity",
+ "properties": {},
+ "lineage": {
+ "nodes": [
+ {"id": "Source", "prov_type": "Entity", "label": "Source"},
+ {"id": "Intermediate", "prov_type": "Entity", "label": "Intermediate"},
+ {"id": "node_id", "prov_type": "Entity", "label": "Target"},
+ ],
+ "edges": [
+ {
+ "id": "Intermediate-node_id",
+ "source": "Intermediate",
+ "target": "node_id",
+ "label": "related_to",
+ "direction": "upstream",
+ },
+ {
+ "id": "Source-Intermediate",
+ "source": "Source",
+ "target": "Intermediate",
+ "label": "related_to",
+ "direction": "lateral",
+ },
+ ],
+ },
+ }
+
+ markdown = _render_markdown(report)
+
+ assert "## Upstream" in markdown
+ assert "## Lateral" in markdown
+ assert "`Intermediate` -[related_to]-> `node_id`" in markdown
+ assert "`Source` -[related_to]-> `Intermediate`" in markdown
diff --git a/tests/export/test_owl_exporter.py b/tests/export/test_owl_exporter.py
new file mode 100644
index 00000000..14a5dc71
--- /dev/null
+++ b/tests/export/test_owl_exporter.py
@@ -0,0 +1,549 @@
+"""Tests for OWLExporter._export_owl_turtle fixes (issue #478).
+
+Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended
+ after a closing period).
+Bug 2: data_properties silently dropped from Turtle output.
+"""
+
+import pytest
+from semantica.export import OWLExporter
+
+
+# ---------------------------------------------------------------------------
+# Shared fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def exporter():
+ return OWLExporter()
+
+
+@pytest.fixture
+def full_ontology():
+ return {
+ "uri": "http://example.org/onto",
+ "name": "TestOntology",
+ "description": "A test ontology",
+ "classes": [
+ {
+ "uri": "http://example.org/Person",
+ "name": "Person",
+ },
+ {
+ "uri": "http://example.org/Employee",
+ "name": "Employee",
+ "comment": "A person who is employed",
+ "subClassOf": "http://example.org/Person",
+ },
+ {
+ "uri": "http://example.org/Manager",
+ "name": "Manager",
+ "subClassOf": "http://example.org/Employee",
+ "equivalentClass": "http://example.org/Supervisor",
+ },
+ ],
+ "object_properties": [
+ {
+ "uri": "http://example.org/worksFor",
+ "name": "worksFor",
+ "domain": "http://example.org/Employee",
+ "range": "http://example.org/Company",
+ },
+ {
+ "uri": "http://example.org/manages",
+ "name": "manages",
+ "comment": "manages a team",
+ "domain": ["http://example.org/Manager"],
+ "range": ["http://example.org/Employee"],
+ },
+ ],
+ "data_properties": [
+ {
+ "uri": "http://example.org/hasAge",
+ "name": "hasAge",
+ "domain": "http://example.org/Person",
+ "range": "integer",
+ },
+ {
+ "uri": "http://example.org/hasName",
+ "name": "hasName",
+ "comment": "full name",
+ "domain": "http://example.org/Person",
+ "range": "string",
+ },
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Bug 1 — valid Turtle syntax
+# ---------------------------------------------------------------------------
+
+class TestTurtleSyntaxValidity:
+ """Every subject block must have exactly one closing period at the end."""
+
+ def _blocks(self, turtle: str) -> list[str]:
+ """Split output into non-empty logical blocks (separated by blank lines)."""
+ return [b.strip() for b in turtle.split("\n\n") if b.strip()]
+
+ def test_no_triple_after_period(self, exporter, full_ontology):
+ """No predicate line may appear after a line that ends with ' .'."""
+ turtle = exporter._export_owl_turtle(full_ontology)
+ lines = turtle.splitlines()
+ for i, line in enumerate(lines):
+ stripped = line.rstrip()
+ if stripped.endswith(" .") and i + 1 < len(lines):
+ next_line = lines[i + 1].strip()
+ # next non-blank line must not be a predicate continuation
+ if next_line:
+ assert not next_line.startswith("rdfs:"), (
+ f"Predicate continuation after closing '.' at line {i + 1}: "
+ f"{lines[i]!r} → {lines[i + 1]!r}"
+ )
+
+ def test_each_subject_block_ends_with_period(self, exporter, full_ontology):
+ """Every subject block (class / property declaration) ends with exactly one '.'."""
+ turtle = exporter._export_owl_turtle(full_ontology)
+ blocks = self._blocks(turtle)
+ # skip the @prefix lines block and ontology declaration
+ subject_blocks = [b for b in blocks if b.startswith("" in turtle
+ # The subClassOf line must NOT come after a closing period
+ lines = turtle.splitlines()
+ for i, ln in enumerate(lines):
+ if "rdfs:subClassOf" in ln:
+ # Search backwards for the closest period-terminated line
+ for prev in reversed(lines[:i]):
+ prev_s = prev.rstrip()
+ if prev_s:
+ assert not prev_s.endswith(" ."), (
+ "rdfs:subClassOf appeared after a closed block"
+ )
+ break
+
+ def test_object_property_with_domain_range_is_valid(self, exporter):
+ ontology = {
+ "uri": "http://example.org/onto",
+ "name": "T",
+ "classes": [],
+ "object_properties": [
+ {
+ "uri": "http://example.org/worksFor",
+ "name": "worksFor",
+ "domain": "http://example.org/Employee",
+ "range": "http://example.org/Company",
+ }
+ ],
+ "data_properties": [],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:domain " in turtle
+ assert "rdfs:range " in turtle
+ lines = turtle.splitlines()
+ for i, ln in enumerate(lines):
+ if "rdfs:domain" in ln or "rdfs:range" in ln:
+ for prev in reversed(lines[:i]):
+ prev_s = prev.rstrip()
+ if prev_s:
+ assert not prev_s.endswith(" ."), (
+ "domain/range appeared after a closed block"
+ )
+ break
+
+ def test_class_with_comment_subclassof_both_present(self, exporter):
+ ontology = {
+ "uri": "http://example.org/onto",
+ "name": "T",
+ "classes": [
+ {
+ "uri": "http://example.org/X",
+ "name": "X",
+ "comment": "some comment",
+ "subClassOf": "http://example.org/Y",
+ }
+ ],
+ "object_properties": [],
+ "data_properties": [],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert 'rdfs:comment "some comment"' in turtle
+ assert "rdfs:subClassOf " in turtle
+ # block must end with single period
+ block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
+ assert block.endswith(".")
+ assert block.count("\n.") == 0 # no bare period-only lines
+
+
+# ---------------------------------------------------------------------------
+# Bug 2 — data properties present in Turtle output
+# ---------------------------------------------------------------------------
+
+class TestDataPropertiesInTurtle:
+
+ def test_data_property_declared_as_datatypeproperty(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "owl:DatatypeProperty" in turtle
+
+ def test_data_property_uri_present(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "" in turtle
+ assert "" in turtle
+
+ def test_data_property_label(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert 'rdfs:label "hasAge"' in turtle
+ assert 'rdfs:label "hasName"' in turtle
+
+ def test_data_property_domain(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "rdfs:domain " in turtle
+
+ def test_data_property_range_uses_xsd_prefix(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "rdfs:range xsd:integer" in turtle
+ assert "rdfs:range xsd:string" in turtle
+
+ def test_data_property_comment(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert 'rdfs:comment "full name"' in turtle
+
+ def test_data_properties_not_in_turtle_was_bug(self, exporter):
+ """Regression: data_properties were silently dropped before the fix."""
+ ontology = {
+ "uri": "http://example.org/onto",
+ "name": "T",
+ "classes": [],
+ "object_properties": [],
+ "data_properties": [
+ {
+ "uri": "http://example.org/birthDate",
+ "name": "birthDate",
+ "range": "date",
+ }
+ ],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "owl:DatatypeProperty" in turtle, (
+ "Data properties must appear in Turtle output (was silently dropped)"
+ )
+ assert "" in turtle
+ assert "rdfs:range xsd:date" in turtle
+
+ def test_data_property_block_ends_with_period(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ blocks = [b.strip() for b in turtle.split("\n\n") if "owl:DatatypeProperty" in b]
+ assert blocks, "Expected at least one DatatypeProperty block"
+ for block in blocks:
+ assert block.endswith("."), f"DatatypeProperty block missing closing '.': {block!r}"
+
+
+# ---------------------------------------------------------------------------
+# Namespace and ontology header
+# ---------------------------------------------------------------------------
+
+class TestTurtleHeader:
+
+ def test_prefix_declarations(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "@prefix rdf:" in turtle
+ assert "@prefix rdfs:" in turtle
+ assert "@prefix owl:" in turtle
+ assert "@prefix xsd:" in turtle
+
+ def test_ontology_declaration(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert "a owl:Ontology" in turtle
+ assert 'rdfs:label "TestOntology"' in turtle
+ assert 'owl:versionInfo "1.0"' in turtle
+
+ def test_ontology_description_included(self, exporter, full_ontology):
+ turtle = exporter._export_owl_turtle(full_ontology)
+ assert 'rdfs:comment "A test ontology"' in turtle
+
+ def test_ontology_without_description(self, exporter):
+ ontology = {"uri": "http://example.org/onto", "name": "NoDesc",
+ "classes": [], "object_properties": [], "data_properties": []}
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:comment" not in turtle
+
+
+# ---------------------------------------------------------------------------
+# Object properties — list domain/range
+# ---------------------------------------------------------------------------
+
+class TestObjectPropertyListDomainRange:
+
+ def test_list_domain(self, exporter):
+ ontology = {
+ "uri": "http://example.org/onto", "name": "T",
+ "classes": [],
+ "object_properties": [
+ {
+ "uri": "http://example.org/p",
+ "name": "p",
+ "domain": ["http://example.org/A", "http://example.org/B"],
+ }
+ ],
+ "data_properties": [],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:domain " in turtle
+ assert "rdfs:domain " in turtle
+
+ def test_list_range(self, exporter):
+ ontology = {
+ "uri": "http://example.org/onto", "name": "T",
+ "classes": [],
+ "object_properties": [
+ {
+ "uri": "http://example.org/p",
+ "name": "p",
+ "range": ["http://example.org/X", "http://example.org/Y"],
+ }
+ ],
+ "data_properties": [],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:range " in turtle
+ assert "rdfs:range " in turtle
+
+
+# ---------------------------------------------------------------------------
+# equivalentClass support (also tested under Bug 1 guard)
+# ---------------------------------------------------------------------------
+
+class TestEquivalentClass:
+
+ def test_equivalent_class_in_turtle(self, exporter):
+ ontology = {
+ "uri": "http://example.org/onto", "name": "T",
+ "classes": [
+ {
+ "uri": "http://example.org/Manager",
+ "name": "Manager",
+ "equivalentClass": "http://example.org/Supervisor",
+ }
+ ],
+ "object_properties": [],
+ "data_properties": [],
+ }
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "owl:equivalentClass " in turtle
+ block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
+ assert block.endswith(".")
+
+
+# ---------------------------------------------------------------------------
+# String escaping in Turtle literals (issue #478 review — escape_001)
+# ---------------------------------------------------------------------------
+
+class TestTurtleStringEscaping:
+ """User-provided strings must be escaped before embedding in Turtle literals."""
+
+ def _onto(self, **kwargs):
+ base = {"uri": "http://example.org/onto", "name": "T",
+ "classes": [], "object_properties": [], "data_properties": []}
+ base.update(kwargs)
+ return base
+
+ def test_escape_ttl_str_double_quote(self, exporter):
+ assert exporter._escape_ttl_str('say "hello"') == r'say \"hello\"'
+
+ def test_escape_ttl_str_backslash(self, exporter):
+ assert exporter._escape_ttl_str("C:\\path") == "C:\\\\path"
+
+ def test_escape_ttl_str_newline(self, exporter):
+ assert exporter._escape_ttl_str("line1\nline2") == "line1\\nline2"
+
+ def test_escape_ttl_str_carriage_return(self, exporter):
+ assert exporter._escape_ttl_str("a\rb") == "a\\rb"
+
+ def test_escape_ttl_str_tab(self, exporter):
+ assert exporter._escape_ttl_str("col1\tcol2") == "col1\\tcol2"
+
+ def test_escape_ttl_str_combined(self, exporter):
+ raw = 'back\\slash and "quote"\nnewline'
+ escaped = exporter._escape_ttl_str(raw)
+ assert '\\"' in escaped
+ assert "\\\\" in escaped
+ assert "\\n" in escaped
+
+ def test_ontology_name_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(name='John"s Ontology')
+ turtle = exporter._export_owl_turtle(ontology)
+ assert 'rdfs:label "John\\"s Ontology"' in turtle
+ assert 'rdfs:label "John"s Ontology"' not in turtle
+
+ def test_ontology_description_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(description='Describes "things"')
+ turtle = exporter._export_owl_turtle(ontology)
+ assert 'rdfs:comment "Describes \\"things\\""' in turtle
+
+ def test_class_name_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(classes=[{
+ "uri": "http://example.org/C",
+ "name": 'My "Special" Class',
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:label "My \"Special\" Class"' in turtle
+
+ def test_class_comment_with_backslash_is_escaped(self, exporter):
+ ontology = self._onto(classes=[{
+ "uri": "http://example.org/C",
+ "name": "C",
+ "comment": "path is C:\\Users",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:comment "path is C:\\Users"' in turtle
+
+ def test_class_comment_with_newline_is_escaped(self, exporter):
+ ontology = self._onto(classes=[{
+ "uri": "http://example.org/C",
+ "name": "C",
+ "comment": "line1\nline2",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:comment "line1\nline2"' in turtle
+
+ def test_object_property_name_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(object_properties=[{
+ "uri": "http://example.org/p",
+ "name": 'has"Value',
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:label "has\"Value"' in turtle
+
+ def test_object_property_comment_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(object_properties=[{
+ "uri": "http://example.org/p",
+ "name": "p",
+ "comment": 'links "A" to "B"',
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:comment "links \"A\" to \"B\""' in turtle
+
+ def test_data_property_name_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(data_properties=[{
+ "uri": "http://example.org/dp",
+ "name": 'the "name" prop',
+ "range": "string",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:label "the \"name\" prop"' in turtle
+
+ def test_data_property_comment_with_quote_is_escaped(self, exporter):
+ ontology = self._onto(data_properties=[{
+ "uri": "http://example.org/dp",
+ "name": "dp",
+ "comment": 'see "spec" §3',
+ "range": "string",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert r'rdfs:comment "see \"spec\" §3"' in turtle
+
+ def test_plain_strings_unchanged(self, exporter):
+ """Strings without special chars must pass through unchanged."""
+ ontology = self._onto(
+ name="MyOntology",
+ classes=[{"uri": "http://example.org/C", "name": "SafeName"}],
+ )
+ turtle = exporter._export_owl_turtle(ontology)
+ assert 'rdfs:label "MyOntology"' in turtle
+ assert 'rdfs:label "SafeName"' in turtle
+
+
+# ---------------------------------------------------------------------------
+# Null / missing optional fields — no KeyError raised (review null_check_001-3)
+# ---------------------------------------------------------------------------
+
+class TestNullFieldHandling:
+ """Optional fields absent from dicts must not raise KeyError."""
+
+ def _onto(self, **kwargs):
+ base = {"uri": "http://example.org/onto", "name": "T",
+ "classes": [], "object_properties": [], "data_properties": []}
+ base.update(kwargs)
+ return base
+
+ def test_class_no_optional_fields(self, exporter):
+ ontology = self._onto(classes=[{"uri": "http://example.org/C", "name": "C"}])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "owl:Class" in turtle
+
+ def test_object_property_no_domain_no_range(self, exporter):
+ ontology = self._onto(object_properties=[{
+ "uri": "http://example.org/p", "name": "p"
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "owl:ObjectProperty" in turtle
+ assert "rdfs:domain" not in turtle
+ assert "rdfs:range" not in turtle
+
+ def test_data_property_no_domain_no_range(self, exporter):
+ ontology = self._onto(data_properties=[{
+ "uri": "http://example.org/dp", "name": "dp"
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "owl:DatatypeProperty" in turtle
+ assert "rdfs:domain" not in turtle
+ assert "rdfs:range" not in turtle
+
+ def test_data_property_none_domain(self, exporter):
+ """Explicit None value for domain must not raise KeyError."""
+ ontology = self._onto(data_properties=[{
+ "uri": "http://example.org/dp", "name": "dp",
+ "domain": None, "range": "string",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:domain" not in turtle
+
+ def test_data_property_none_range(self, exporter):
+ """Explicit None value for range must not raise KeyError."""
+ ontology = self._onto(data_properties=[{
+ "uri": "http://example.org/dp", "name": "dp",
+ "domain": "http://example.org/C", "range": None,
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:range" not in turtle
+
+ def test_object_property_none_domain(self, exporter):
+ ontology = self._onto(object_properties=[{
+ "uri": "http://example.org/p", "name": "p",
+ "domain": None, "range": "http://example.org/X",
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:domain" not in turtle
+
+ def test_object_property_none_range(self, exporter):
+ ontology = self._onto(object_properties=[{
+ "uri": "http://example.org/p", "name": "p",
+ "domain": "http://example.org/A", "range": None,
+ }])
+ turtle = exporter._export_owl_turtle(ontology)
+ assert "rdfs:range" not in turtle
diff --git a/tests/kg/test_path_finder.py b/tests/kg/test_path_finder.py
index 7c76abbd..a4fab78a 100644
--- a/tests/kg/test_path_finder.py
+++ b/tests/kg/test_path_finder.py
@@ -821,3 +821,87 @@ class TestPathFinderEdgeCases:
paths = self.finder.all_shortest_paths(single_node_graph, "A")
assert len(paths) == 0 # No paths to other nodes
+
+
+class TestBidirectionalPathFinding:
+ """Tests for the directed=False undirected-traversal mode (issue #469)."""
+
+ def setup_method(self):
+ self.finder = PathFinder()
+ # Single directed edge A → B. Reverse query B → A has no directed path.
+ self.digraph = nx.DiGraph()
+ self.digraph.add_edge("A", "B")
+
+ # --- directed=True (default) preserves existing behaviour ---
+
+ def test_bfs_directed_true_reverse_returns_empty(self):
+ """B→A should find nothing when directed=True (default)."""
+ path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=True)
+ assert path == []
+
+ def test_dijkstra_directed_true_reverse_returns_empty(self):
+ """B→A should find nothing when directed=True (default)."""
+ path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=True)
+ assert path == []
+
+ def test_bfs_directed_true_default_arg(self):
+ """Omitting directed= should behave the same as directed=True."""
+ path = self.finder.bfs_shortest_path(self.digraph, "B", "A")
+ assert path == []
+
+ def test_dijkstra_directed_true_default_arg(self):
+ path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A")
+ assert path == []
+
+ # --- directed=False finds path against edge orientation ---
+
+ def test_bfs_directed_false_reverse_single_edge(self):
+ """directed=False must find B→A even though only A→B exists."""
+ path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=False)
+ assert path == ["B", "A"]
+
+ def test_dijkstra_directed_false_reverse_single_edge(self):
+ path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=False)
+ assert path == ["B", "A"]
+
+ def test_bfs_directed_false_forward_still_works(self):
+ """directed=False should not break the forward direction."""
+ path = self.finder.bfs_shortest_path(self.digraph, "A", "B", directed=False)
+ assert path == ["A", "B"]
+
+ def test_dijkstra_directed_false_forward_still_works(self):
+ path = self.finder.dijkstra_shortest_path(self.digraph, "A", "B", directed=False)
+ assert path == ["A", "B"]
+
+ # --- multi-hop path where one edge is against the query direction ---
+
+ def test_bfs_directed_false_multihop(self):
+ """A→B, C→B graph: directed=False lets us find A→B→C (i.e. A→C via B)."""
+ g = nx.DiGraph()
+ g.add_edge("A", "B")
+ g.add_edge("C", "B") # oriented towards B, not away from it
+ # undirected view: A-B-C, so A→C path exists
+ path = self.finder.bfs_shortest_path(g, "A", "C", directed=False)
+ assert path[0] == "A" and path[-1] == "C"
+ assert "B" in path
+
+ def test_dijkstra_directed_false_multihop(self):
+ g = nx.DiGraph()
+ g.add_edge("A", "B")
+ g.add_edge("C", "B")
+ path = self.finder.dijkstra_shortest_path(g, "A", "C", directed=False)
+ assert path[0] == "A" and path[-1] == "C"
+ assert "B" in path
+
+ # --- PathResponse.directed field ---
+
+ def test_path_response_directed_field_exists(self):
+ """PathResponse must carry a directed field."""
+ from semantica.explorer.schemas import PathResponse
+ r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"], directed=False)
+ assert r.directed is False
+
+ def test_path_response_directed_field_defaults_true(self):
+ from semantica.explorer.schemas import PathResponse
+ r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"])
+ assert r.directed is True
diff --git a/tests/semantic_extract/test_pr482_deepseek_openai.py b/tests/semantic_extract/test_pr482_deepseek_openai.py
new file mode 100644
index 00000000..a0b12e74
--- /dev/null
+++ b/tests/semantic_extract/test_pr482_deepseek_openai.py
@@ -0,0 +1,348 @@
+"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
+
+import sys
+import os
+import unittest
+from unittest.mock import patch, MagicMock, call
+from pydantic import BaseModel
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
+
+
+class TestDeepSeekProviderInit(unittest.TestCase):
+ """Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
+
+ def setUp(self):
+ from semantica.semantic_extract.providers import DeepSeekProvider
+ self.DeepSeekProvider = DeepSeekProvider
+
+ def test_base_url_set_on_init(self):
+ """self.base_url must be set before _init_client is called (PR #482 regression)."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="fake-key")
+ self.assertTrue(
+ hasattr(provider, "base_url"),
+ "DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
+ )
+ self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
+
+ def test_base_url_points_to_v1_endpoint(self):
+ """base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="fake-key")
+ self.assertIn("/v1", provider.base_url, "base_url must include /v1")
+
+ def test_init_client_uses_openai_not_deepseek(self):
+ """_init_client must import openai.OpenAI, not deepseek.Client."""
+ mock_openai_cls = MagicMock()
+ mock_openai_instance = MagicMock()
+ mock_openai_cls.return_value = mock_openai_instance
+
+ with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
+ # Re-import to pick up patched sys.modules
+ import importlib
+ import semantica.semantic_extract.providers as providers_mod
+ importlib.reload(providers_mod)
+ DeepSeekProvider = providers_mod.DeepSeekProvider
+
+ provider = DeepSeekProvider(api_key="sk-test")
+
+ mock_openai_cls.assert_called_once_with(
+ api_key="sk-test",
+ base_url="https://api.deepseek.com/v1",
+ )
+ self.assertIs(provider.client, mock_openai_instance)
+
+ def test_init_client_no_api_key_leaves_client_none(self):
+ """Without an API key, client must remain None."""
+ with patch("semantica.semantic_extract.providers.config") as mock_cfg:
+ mock_cfg.get_api_key.return_value = None
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key=None)
+ provider.client = None # simulate _init_client no-op
+ self.assertFalse(provider.is_available())
+
+ def test_init_client_handles_openai_import_error(self):
+ """If openai is not installed, _init_client must set client=None, not raise."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="sk-test")
+ provider.client = None # manually simulate ImportError path
+ # Directly call _init_client with openai blocked
+ with patch.dict("sys.modules", {"openai": None}):
+ try:
+ provider._init_client()
+ except Exception as e:
+ self.fail(f"_init_client raised unexpectedly: {e}")
+ self.assertIsNone(provider.client)
+
+ def test_is_available_true_when_client_set(self):
+ """is_available() returns True when self.client is an OpenAI instance."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="sk-test")
+ provider.client = MagicMock()
+ self.assertTrue(provider.is_available())
+
+ def test_is_available_false_when_client_none(self):
+ """is_available() returns False when self.client is None."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="sk-test")
+ provider.client = None
+ self.assertFalse(provider.is_available())
+
+ def test_no_deepseek_module_imported(self):
+ """deepseek module must NOT be imported by _init_client after PR #482."""
+ with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
+ provider = self.DeepSeekProvider(api_key="sk-test")
+ provider.client = None
+
+ blocked = MagicMock()
+ blocked.__spec__ = None
+ with patch.dict("sys.modules", {"deepseek": None}):
+ # _init_client should succeed even if deepseek is completely absent
+ mock_openai = MagicMock()
+ mock_openai.OpenAI.return_value = MagicMock()
+ with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
+ try:
+ provider._init_client()
+ except Exception as e:
+ self.fail(f"_init_client raised when deepseek absent: {e}")
+
+
+class TestDeepSeekProviderGenerate(unittest.TestCase):
+ """Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
+
+ def _make_provider(self, api_key="sk-test"):
+ from semantica.semantic_extract.providers import DeepSeekProvider
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key=api_key)
+ provider.client = MagicMock()
+ return provider
+
+ def test_generate_uses_chat_completions(self):
+ """generate() must call client.chat.completions.create."""
+ provider = self._make_provider()
+ mock_resp = MagicMock()
+ mock_resp.choices[0].message.content = "hello"
+ provider.client.chat.completions.create.return_value = mock_resp
+
+ result = provider.generate("test prompt")
+
+ provider.client.chat.completions.create.assert_called_once()
+ self.assertEqual(result, "hello")
+
+ def test_generate_passes_model(self):
+ provider = self._make_provider()
+ mock_resp = MagicMock()
+ mock_resp.choices[0].message.content = "x"
+ provider.client.chat.completions.create.return_value = mock_resp
+
+ provider.generate("p", model="deepseek-reasoner")
+ kwargs = provider.client.chat.completions.create.call_args[1]
+ self.assertEqual(kwargs["model"], "deepseek-reasoner")
+
+ def test_generate_structured_returns_parsed_json(self):
+ provider = self._make_provider()
+ mock_resp = MagicMock()
+ mock_resp.choices[0].message.content = '{"key": "value"}'
+ provider.client.chat.completions.create.return_value = mock_resp
+
+ result = provider.generate_structured("test prompt")
+ self.assertEqual(result, {"key": "value"})
+
+ def test_generate_raises_without_client(self):
+ from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key="sk-test")
+ provider.client = None
+
+ with self.assertRaises(ProcessingError):
+ provider.generate("prompt")
+
+ def test_generate_structured_raises_without_client(self):
+ from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key="sk-test")
+ provider.client = None
+
+ with self.assertRaises(ProcessingError):
+ provider.generate_structured("prompt")
+
+
+class TestDeepSeekInstructorPath(unittest.TestCase):
+ """Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
+
+ def _make_provider(self, api_key="sk-test"):
+ from semantica.semantic_extract.providers import DeepSeekProvider
+ from unittest.mock import MagicMock
+ from openai import OpenAI
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key=api_key)
+ # After PR #482, client is an OpenAI instance
+ mock_client = MagicMock(spec=OpenAI)
+ provider.client = mock_client
+ return provider
+
+ def test_generate_typed_instructor_openai_isinstance_check(self):
+ """After PR #482, client is OpenAI, so instructor path must use from_openai."""
+ from openai import OpenAI
+ from semantica.semantic_extract.providers import DeepSeekProvider
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key="sk-test")
+ provider.client = MagicMock(spec=OpenAI)
+
+ self.assertIsInstance(
+ provider.client, OpenAI,
+ "client must be OpenAI instance for instructor isinstance check to pass",
+ )
+
+
+class TestVerboseModeAssignment(unittest.TestCase):
+ """Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
+
+ def _make_openai_provider(self):
+ from semantica.semantic_extract.providers import OpenAIProvider
+ with patch.object(OpenAIProvider, "_init_client", return_value=None):
+ provider = OpenAIProvider(api_key="sk-test")
+ provider.client = MagicMock()
+ return provider
+
+ def test_generate_typed_no_verbose_no_name_error(self):
+ """generate_typed must not raise NameError for verbose_mode when verbose not passed."""
+ provider = self._make_openai_provider()
+
+ class Schema(BaseModel):
+ value: str
+
+ mock_instructor = MagicMock()
+ mock_client = MagicMock()
+ mock_client.chat.completions.create.return_value = Schema(value="ok")
+ mock_instructor.from_openai.return_value = mock_client
+ mock_instructor.from_provider.side_effect = Exception("skip")
+ mock_instructor.Mode.TOOLS = "tools"
+
+ with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
+ try:
+ result = provider.generate_typed("prompt", Schema)
+ except NameError as e:
+ self.fail(f"NameError for verbose_mode: {e}")
+ except Exception:
+ pass # other errors are OK — we only care NameError is gone
+
+ def test_generate_typed_verbose_true_prints(self):
+ """When verbose=True, generate_typed must print the confirmation line."""
+ provider = self._make_openai_provider()
+
+ class Schema(BaseModel):
+ value: str
+
+ mock_schema_instance = Schema(value="ok")
+ mock_instructor = MagicMock()
+ mock_ic_client = MagicMock()
+ mock_ic_client.chat.completions.create.return_value = mock_schema_instance
+ mock_instructor.from_openai.return_value = mock_ic_client
+ mock_instructor.from_provider.side_effect = Exception("skip")
+ mock_instructor.Mode.TOOLS = "tools"
+
+ import io
+ captured = io.StringIO()
+ with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
+ with patch("sys.stdout", captured):
+ try:
+ provider.generate_typed("prompt", Schema, verbose=True)
+ except Exception:
+ pass
+
+ output = captured.getvalue()
+ # verbose_mode=True should trigger the print statement
+ self.assertIn("generate_typed", output)
+
+ def test_generate_typed_verbose_false_no_print(self):
+ """When verbose=False (default), generate_typed must not print anything."""
+ provider = self._make_openai_provider()
+
+ class Schema(BaseModel):
+ value: str
+
+ mock_schema_instance = Schema(value="ok")
+ mock_instructor = MagicMock()
+ mock_ic_client = MagicMock()
+ mock_ic_client.chat.completions.create.return_value = mock_schema_instance
+ mock_instructor.from_openai.return_value = mock_ic_client
+ mock_instructor.from_provider.side_effect = Exception("skip")
+ mock_instructor.Mode.TOOLS = "tools"
+
+ import io
+ captured = io.StringIO()
+ with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
+ with patch("sys.stdout", captured):
+ try:
+ provider.generate_typed("prompt", Schema)
+ except Exception:
+ pass
+
+ self.assertEqual(captured.getvalue(), "")
+
+ def test_generate_typed_verbose_from_config(self):
+ """verbose_mode must also respect config-level verbose setting."""
+ provider = self._make_openai_provider()
+ provider.config["verbose"] = True
+
+ class Schema(BaseModel):
+ value: str
+
+ mock_schema_instance = Schema(value="ok")
+ mock_instructor = MagicMock()
+ mock_ic_client = MagicMock()
+ mock_ic_client.chat.completions.create.return_value = mock_schema_instance
+ mock_instructor.from_openai.return_value = mock_ic_client
+ mock_instructor.from_provider.side_effect = Exception("skip")
+ mock_instructor.Mode.TOOLS = "tools"
+
+ import io
+ captured = io.StringIO()
+ with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
+ with patch("sys.stdout", captured):
+ try:
+ provider.generate_typed("prompt", Schema)
+ except Exception:
+ pass
+
+ self.assertIn("generate_typed", captured.getvalue())
+
+
+class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
+ """Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
+
+ def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
+ """generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
+ from semantica.semantic_extract.providers import DeepSeekProvider
+ from openai import OpenAI
+
+ with patch.object(DeepSeekProvider, "_init_client", return_value=None):
+ provider = DeepSeekProvider(api_key="sk-test")
+ mock_openai_client = MagicMock(spec=OpenAI)
+ provider.client = mock_openai_client
+
+ class Schema(BaseModel):
+ label: str
+
+ mock_instructor = MagicMock()
+ mock_ic_client = MagicMock()
+ mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
+ mock_instructor.from_openai.return_value = mock_ic_client
+ mock_instructor.from_provider.side_effect = Exception("no from_provider")
+ mock_instructor.Mode.JSON = "json"
+ mock_instructor.Mode.TOOLS = "tools"
+
+ with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
+ result = provider.generate_typed("classify this", Schema)
+
+ # Must have called from_openai with the existing client (not a fresh one)
+ mock_instructor.from_openai.assert_called_once_with(
+ mock_openai_client, mode="json"
+ )
+ self.assertEqual(result.label, "ok")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/visualization/test_kg_visualizer_normalize_graph.py b/tests/visualization/test_kg_visualizer_normalize_graph.py
new file mode 100644
index 00000000..055bcdc3
--- /dev/null
+++ b/tests/visualization/test_kg_visualizer_normalize_graph.py
@@ -0,0 +1,448 @@
+"""
+Tests for KGVisualizer._normalize_graph() and the fix for issue #458:
+ "KGVisualizer.visualize_network() does not accept a KnowledgeGraph object"
+
+All public visualize_* methods must accept either:
+ - a plain dict {"entities": [...], "relationships": [...]}
+ - any object exposing .entities / .relationships attributes
+and must raise a clear ProcessingError for anything else.
+"""
+
+import contextlib
+import sys
+import unittest
+from dataclasses import dataclass, field
+from typing import List
+from unittest.mock import MagicMock, patch
+
+# ---------------------------------------------------------------------------
+# Stub out heavy optional deps before importing the module under test
+# ---------------------------------------------------------------------------
+sys.modules.setdefault("matplotlib", MagicMock())
+sys.modules.setdefault("matplotlib.pyplot", MagicMock())
+sys.modules.setdefault("matplotlib.patches", MagicMock())
+sys.modules.setdefault("plotly", MagicMock())
+sys.modules.setdefault("plotly.express", MagicMock())
+sys.modules.setdefault("plotly.graph_objects", MagicMock())
+sys.modules.setdefault("plotly.subplots", MagicMock())
+sys.modules.setdefault("graphviz", MagicMock())
+sys.modules.setdefault("seaborn", MagicMock())
+
+from semantica.utils.exceptions import ProcessingError # noqa: E402
+from semantica.visualization.kg_visualizer import KGVisualizer # noqa: E402
+
+# ---------------------------------------------------------------------------
+# Minimal fixtures
+# ---------------------------------------------------------------------------
+ENTITIES = [
+ {"id": "e1", "text": "Alice", "type": "Person"},
+ {"id": "e2", "text": "Bob", "type": "Person"},
+]
+RELATIONSHIPS = [
+ {"source": "e1", "target": "e2", "type": "KNOWS"},
+]
+GRAPH_DICT = {"entities": ENTITIES, "relationships": RELATIONSHIPS}
+
+
+@dataclass
+class SimpleKG:
+ """Minimal KnowledgeGraph-like dataclass (mimics GraphBuilder output)."""
+ entities: List[dict] = field(default_factory=list)
+ relationships: List[dict] = field(default_factory=list)
+ metadata: dict = field(default_factory=dict)
+
+
+class NamespaceKG:
+ """Object-with-attributes variant (no dataclass decorator)."""
+ def __init__(self, entities, relationships, metadata=None):
+ self.entities = entities
+ self.relationships = relationships
+ self.metadata = metadata or {}
+
+
+# ---------------------------------------------------------------------------
+# Helper: build a KGVisualizer with all heavy internals mocked out
+# ---------------------------------------------------------------------------
+
+def _make_viz():
+ mock_logger = MagicMock()
+ mock_tracker = MagicMock()
+ mock_tracker.enabled = True
+ mock_tracker.start_tracking.return_value = "tid"
+
+ patches = [
+ patch("semantica.visualization.kg_visualizer.get_logger", return_value=mock_logger),
+ patch("semantica.visualization.kg_visualizer.get_progress_tracker", return_value=mock_tracker),
+ patch("semantica.visualization.kg_visualizer.ForceDirectedLayout", MagicMock()),
+ patch("semantica.visualization.kg_visualizer.HierarchicalLayout", MagicMock()),
+ patch("semantica.visualization.kg_visualizer.CircularLayout", MagicMock()),
+ ]
+ with contextlib.ExitStack() as stack:
+ for p in patches:
+ stack.enter_context(p)
+ viz = KGVisualizer(layout="force")
+
+ viz.logger = mock_logger
+ viz.progress_tracker = mock_tracker
+ return viz
+
+
+# ---------------------------------------------------------------------------
+# Tests for _normalize_graph directly
+# ---------------------------------------------------------------------------
+
+class TestNormalizeGraph(unittest.TestCase):
+ """Unit tests for _normalize_graph — no Plotly calls needed."""
+
+ def setUp(self):
+ self.viz = _make_viz()
+
+ # --- dict input ---
+
+ def test_dict_passthrough(self):
+ result = self.viz._normalize_graph(GRAPH_DICT)
+ self.assertIs(result, GRAPH_DICT, "_normalize_graph should return the same dict unchanged")
+
+ def test_dict_missing_keys_passthrough(self):
+ """A dict without entities/relationships is still passed through; callers handle emptiness."""
+ result = self.viz._normalize_graph({})
+ self.assertIsInstance(result, dict)
+
+ # --- object-with-attributes input ---
+
+ def test_dataclass_kg(self):
+ kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+ result = self.viz._normalize_graph(kg)
+ self.assertEqual(result["entities"], ENTITIES)
+ self.assertEqual(result["relationships"], RELATIONSHIPS)
+
+ def test_namespace_kg(self):
+ kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+ result = self.viz._normalize_graph(kg)
+ self.assertEqual(result["entities"], ENTITIES)
+ self.assertEqual(result["relationships"], RELATIONSHIPS)
+
+ def test_object_with_only_entities(self):
+ """An object with only .entities (no .relationships) should still work."""
+ class EntitiesOnly:
+ entities = ENTITIES
+ result = self.viz._normalize_graph(EntitiesOnly())
+ self.assertEqual(result["entities"], ENTITIES)
+ self.assertEqual(result["relationships"], [])
+
+ def test_object_with_only_relationships(self):
+ """An object with only .relationships (no .entities) should still work."""
+ class RelsOnly:
+ relationships = RELATIONSHIPS
+ result = self.viz._normalize_graph(RelsOnly())
+ self.assertEqual(result["entities"], [])
+ self.assertEqual(result["relationships"], RELATIONSHIPS)
+
+ def test_metadata_propagated(self):
+ kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS, metadata={"version": "1"})
+ result = self.viz._normalize_graph(kg)
+ self.assertEqual(result["metadata"], {"version": "1"})
+
+ def test_metadata_defaults_to_empty_dict(self):
+ kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+ kg.metadata = None
+ result = self.viz._normalize_graph(kg)
+ self.assertEqual(result["metadata"], {})
+
+ # --- unsupported types ---
+
+ def test_raises_for_string(self):
+ with self.assertRaises(ProcessingError) as ctx:
+ self.viz._normalize_graph("not a graph")
+ self.assertIn("str", str(ctx.exception))
+
+ def test_raises_for_integer(self):
+ with self.assertRaises(ProcessingError):
+ self.viz._normalize_graph(42)
+
+ def test_raises_for_list(self):
+ with self.assertRaises(ProcessingError):
+ self.viz._normalize_graph([{"id": "e1"}])
+
+ def test_raises_for_none(self):
+ with self.assertRaises((ProcessingError, AttributeError)):
+ self.viz._normalize_graph(None)
+
+ def test_error_message_names_type(self):
+ class WeirdThing:
+ pass
+ with self.assertRaises(ProcessingError) as ctx:
+ self.viz._normalize_graph(WeirdThing())
+ self.assertIn("WeirdThing", str(ctx.exception))
+
+
+# ---------------------------------------------------------------------------
+# Integration: visualize_network accepts KG objects end-to-end
+# ---------------------------------------------------------------------------
+
+class TestVisualizeNetworkAcceptsKGObject(unittest.TestCase):
+ """
+ Regression tests for issue #458.
+
+ visualize_network() must produce the same result whether it receives a
+ dict or an equivalent KG object.
+ """
+
+ def _run_visualize_network(self, graph_arg):
+ """Run visualize_network with all Plotly internals mocked."""
+ mock_fig = MagicMock()
+ mock_go = sys.modules["plotly.graph_objects"]
+ mock_go.Figure.return_value = mock_fig
+ mock_go.Scatter.return_value = MagicMock()
+ mock_go.Layout.return_value = MagicMock()
+
+ viz = _make_viz()
+
+ # Mock layout to return deterministic positions
+ fake_pos = {"e1": (0.0, 0.0), "e2": (1.0, 1.0)}
+ viz.force_layout = MagicMock()
+ viz.force_layout.compute_layout.return_value = fake_pos
+ viz.hierarchical_layout = MagicMock()
+ viz.circular_layout = MagicMock()
+
+ # ColorPalette helpers
+ with (
+ patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
+ return_value={"Person": "#ff0000"},
+ ),
+ patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_colors",
+ return_value=["#ff0000"],
+ ),
+ ):
+ return viz.visualize_network(graph_arg, output="interactive")
+
+ def test_dict_input_returns_figure(self):
+ fig = self._run_visualize_network(GRAPH_DICT)
+ self.assertIsNotNone(fig)
+
+ def test_dataclass_kg_returns_figure(self):
+ """Issue #458: passing a KnowledgeGraph dataclass must not be a silent no-op."""
+ kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+ fig = self._run_visualize_network(kg)
+ self.assertIsNotNone(fig)
+
+ def test_namespace_kg_returns_figure(self):
+ kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+ fig = self._run_visualize_network(kg)
+ self.assertIsNotNone(fig)
+
+ def test_unsupported_type_raises_processing_error(self):
+ viz = _make_viz()
+ with self.assertRaises(ProcessingError):
+ viz.visualize_network("not a graph")
+
+
+# ---------------------------------------------------------------------------
+# Integration: all other visualize_* methods also accept KG objects
+# ---------------------------------------------------------------------------
+
+class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
+ """Each public visualize_* method must call _normalize_graph."""
+
+ def setUp(self):
+ self.viz = _make_viz()
+ self.kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
+
+ def test_visualize_communities_accepts_kg_object(self):
+ self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
+ communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
+ with patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
+ return_value=["#ff0000", "#00ff00"],
+ ):
+ self.viz.visualize_communities(self.kg, communities=communities)
+ self.viz._normalize_graph.assert_called_once_with(self.kg)
+
+ def test_visualize_centrality_accepts_kg_object(self):
+ self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
+ self.viz.visualize_centrality(self.kg, centrality={"centrality": {}})
+ self.viz._normalize_graph.assert_called_once_with(self.kg)
+
+ def test_visualize_entity_types_accepts_kg_object(self):
+ self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ mock_px = sys.modules["plotly.express"]
+ mock_px.bar.return_value = MagicMock()
+ self.viz.visualize_entity_types(self.kg)
+ self.viz._normalize_graph.assert_called_once_with(self.kg)
+
+ def test_visualize_relationship_matrix_accepts_kg_object(self):
+ self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ mock_go = sys.modules["plotly.graph_objects"]
+ mock_go.Figure.return_value = MagicMock()
+ mock_go.Heatmap.return_value = MagicMock()
+ self.viz.visualize_relationship_matrix(self.kg)
+ self.viz._normalize_graph.assert_called_once_with(self.kg)
+
+
+# ---------------------------------------------------------------------------
+# Issue #471 — formal KnowledgeGraph type support
+# ---------------------------------------------------------------------------
+
+class TestFormalKnowledgeGraphType(unittest.TestCase):
+ """
+ Regression tests for issue #471.
+
+ The formal ``semantica.kg.KnowledgeGraph`` dataclass must be accepted by
+ every public visualize_* method without requiring any manual conversion.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ try:
+ from semantica.kg.knowledge_graph import KnowledgeGraph
+ cls.KnowledgeGraph = KnowledgeGraph
+ except ImportError:
+ cls.KnowledgeGraph = None
+
+ def _make_kg(self):
+ if self.KnowledgeGraph is None:
+ self.skipTest("semantica.kg.KnowledgeGraph not available")
+ return self.KnowledgeGraph(
+ entities=ENTITIES,
+ relationships=RELATIONSHIPS,
+ metadata={"version": "test"},
+ )
+
+ def test_convert_knowledge_graph_entities(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ result = viz._convert_knowledge_graph(kg)
+ self.assertEqual(result["entities"], ENTITIES)
+
+ def test_convert_knowledge_graph_relationships(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ result = viz._convert_knowledge_graph(kg)
+ self.assertEqual(result["relationships"], RELATIONSHIPS)
+
+ def test_convert_knowledge_graph_metadata(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ result = viz._convert_knowledge_graph(kg)
+ self.assertEqual(result["metadata"], {"version": "test"})
+
+ def test_convert_knowledge_graph_does_not_mutate(self):
+ kg = self._make_kg()
+ original_entities = list(kg.entities)
+ original_relationships = list(kg.relationships)
+ viz = _make_viz()
+ viz._convert_knowledge_graph(kg)
+ self.assertEqual(kg.entities, original_entities)
+ self.assertEqual(kg.relationships, original_relationships)
+
+ def test_convert_knowledge_graph_is_deterministic(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ self.assertEqual(viz._convert_knowledge_graph(kg), viz._convert_knowledge_graph(kg))
+
+ def test_normalize_graph_routes_kg_type(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ viz._convert_knowledge_graph = MagicMock(return_value=GRAPH_DICT)
+ viz._normalize_graph(kg)
+ viz._convert_knowledge_graph.assert_called_once_with(kg)
+
+ def test_normalize_graph_returns_dict_for_kg_type(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ result = viz._normalize_graph(kg)
+ self.assertIsInstance(result, dict)
+ self.assertIn("entities", result)
+ self.assertIn("relationships", result)
+
+ def _run_visualize_network(self, graph_arg):
+ mock_fig = MagicMock()
+ mock_go = sys.modules["plotly.graph_objects"]
+ mock_go.Figure.return_value = mock_fig
+ mock_go.Scatter.return_value = MagicMock()
+ mock_go.Layout.return_value = MagicMock()
+ viz = _make_viz()
+ fake_pos = {"e1": (0.0, 0.0), "e2": (1.0, 1.0)}
+ viz.force_layout = MagicMock()
+ viz.force_layout.compute_layout.return_value = fake_pos
+ viz.hierarchical_layout = MagicMock()
+ viz.circular_layout = MagicMock()
+ with (
+ patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
+ return_value={"Person": "#ff0000"},
+ ),
+ patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_colors",
+ return_value=["#ff0000"],
+ ),
+ ):
+ return viz.visualize_network(graph_arg, output="interactive")
+
+ def test_visualize_network_accepts_knowledge_graph(self):
+ self.assertIsNotNone(self._run_visualize_network(self._make_kg()))
+
+ def test_visualize_communities_accepts_knowledge_graph(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
+ communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
+ with patch(
+ "semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
+ return_value=["#ff0000", "#00ff00"],
+ ):
+ viz.visualize_communities(kg, communities=communities)
+ viz._normalize_graph.assert_called_once_with(kg)
+
+ def test_visualize_centrality_accepts_knowledge_graph(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
+ viz.visualize_centrality(kg, centrality={"centrality": {}})
+ viz._normalize_graph.assert_called_once_with(kg)
+
+ def test_visualize_entity_types_accepts_knowledge_graph(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ sys.modules["plotly.express"].bar.return_value = MagicMock()
+ viz.visualize_entity_types(kg)
+ viz._normalize_graph.assert_called_once_with(kg)
+
+ def test_visualize_relationship_matrix_accepts_knowledge_graph(self):
+ kg = self._make_kg()
+ viz = _make_viz()
+ viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
+ sys.modules["plotly.graph_objects"].Figure.return_value = MagicMock()
+ sys.modules["plotly.graph_objects"].Heatmap.return_value = MagicMock()
+ viz.visualize_relationship_matrix(kg)
+ viz._normalize_graph.assert_called_once_with(kg)
+
+ def test_knowledge_graph_importable_from_kg_module(self):
+ if self.KnowledgeGraph is None:
+ self.skipTest("semantica.kg.KnowledgeGraph not available")
+ try:
+ import semantica.kg as _kg_module
+ _ = _kg_module.KnowledgeGraph
+ except (ImportError, AttributeError) as exc:
+ self.fail(f"KnowledgeGraph not exported from semantica.kg: {exc}")
+
+ def test_knowledge_graph_empty_defaults(self):
+ kg = self.KnowledgeGraph()
+ self.assertEqual(kg.entities, [])
+ self.assertEqual(kg.relationships, [])
+ self.assertFalse(bool(kg))
+
+ def test_knowledge_graph_len(self):
+ kg = self._make_kg()
+ self.assertEqual(len(kg), len(ENTITIES))
+
+
+if __name__ == "__main__":
+ unittest.main()