Compare commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:14:50 +05:30
68 changed files with 1588 additions and 7088 deletions
-1
View File
@@ -1 +0,0 @@
# Initialization
-1
View File
@@ -1 +0,0 @@
# Intialization
-1
View File
@@ -1 +0,0 @@
# Initialization
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
continue-on-error: true
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@v3
with:
path: ./site
-7
View File
@@ -111,12 +111,5 @@ 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/
# Local graph explorer test datasets
demo_out/
-51
View File
@@ -7,57 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
- parallel-edge aggregation in full view
- collapse behavior preserving active-path neighbors
- grouped community-node/community-edge projection behavior
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
- **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 24471 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 (212 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"` (01 hops), `"near"` (23), `"mid-range"` (46), `"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):
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:25-alpine AS frontend-builder
FROM node:20-alpine AS frontend-builder
WORKDIR /app/semantica-explorer
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
RUN npm run build
FROM python:3.14-slim AS runtime
FROM python:3.12-slim AS runtime
WORKDIR /app
+109 -70
View File
@@ -46,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 **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
```bash
pip install semantica
@@ -170,75 +170,6 @@ Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an
</table>
### Agentic Frameworks
Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
<table>
<tr>
<th colspan="8" align="left">✅ Supported</th>
</tr>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="48" height="48" /></a><br/>
<strong>Agno</strong><br/>
<sub>First-class · <code>pip install semantica[agno]</code></sub>
</td>
</tr>
<tr>
<th colspan="8" align="left">🔜 Coming Soon</th>
</tr>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
<strong>LangChain</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
<strong>LangGraph</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
<strong>LlamaIndex</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="48" height="48" /></a><br/>
<strong>AutoGen</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="48" height="48" /></a><br/>
<strong>OpenAI Agents</strong><br/>
<sub>Coming soon</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="48" height="48" /></a><br/>
<strong>Google ADK</strong><br/>
<sub>Coming soon</sub>
</td>
</tr>
</table>
> **Agno — First-Class Integration** · `pip install semantica[agno]`
>
> Five integration modules live in [`integrations/agno/`](integrations/agno/):
>
> | Module | Class | What it does |
> |---|---|---|
> | `context_store.py` | `AgnoContextStore` | Graph-backed agent memory — store and retrieve structured context |
> | `knowledge_graph.py` | `AgnoKnowledgeGraph` | Implements Agno's `AgentKnowledge` protocol; full extraction pipeline |
> | `decision_kit.py` | `AgnoDecisionKit` | 6 decision-intelligence tools for Agno agents |
> | `kg_toolkit.py` | `AgnoKGToolkit` | 7 KG pipeline tools (build, query, enrich, export) |
> | `shared_context.py` | `AgnoSharedContext` | Shared context graph for multi-agent team coordination |
### Plugin Bundles (Claude Code · Cursor · Codex)
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
@@ -944,6 +875,53 @@ if result.valid:
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
---
## 🔌 Integrations
### AI Coding Tools & IDEs
Start the Semantica server (`python -m semantica.server`, port 8000) and point any tool at `http://localhost:8000`. Tools marked **Native plugin** also get 17 skills, 3 agents, and hook config out of the box.
| Tool | Connection | Notes |
|---|---|---|
| [Claude Code](https://claude.com/product/claude-code) | **Native plugin** | `plugins/.claude-plugin/` — 17 skills, 3 agents, `hooks.json` |
| [Cursor](https://cursor.com) | **Native plugin** | `plugins/.cursor-plugin/` — same 17 skills + 3 agents |
| [Codex CLI](https://github.com/openai/codex) | **Native plugin** | `plugins/.codex-plugin/` — same 17 skills + 3 agents |
| [Windsurf](https://windsurf.com) | **MCP server** + plugin | `plugins/.windsurf-plugin/` · add `python -m semantica.mcp_server` to `~/.codeium/windsurf/mcp_config.json` |
| [Claude Desktop](https://claude.ai/download) | **MCP server** | Add `python -m semantica.mcp_server` to `claude_desktop_config.json` |
| [VS Code](https://github.com/microsoft/vscode) | **MCP server** + plugin | `plugins/.vscode-plugin/` · add to `settings.json` under `mcp.servers` |
| [GitHub Copilot](https://github.com/features/copilot) | REST API | Use via Copilot Chat custom tools |
| [Cline](https://github.com/cline/cline) | **MCP server** + plugin | `plugins/.cline-plugin/` · add server in Cline MCP settings panel |
| [Roo Code](https://github.com/RooCodeInc/Roo-Code) | **MCP server** | Add `python -m semantica.mcp_server` in Roo Code MCP settings |
| [Continue](https://github.com/continuedev/continue) | **MCP server** + plugin | `plugins/.continue-plugin/` · add to `~/.continue/config.json` under `mcpServers` |
| [Goose](https://github.com/block/goose) | REST API | Add to Goose toolset config |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | REST API | Add as custom REST tool |
| [Aider](https://github.com/Aider-AI/aider) | REST API | Pass context from the API into prompts |
| [Amazon Q Developer](https://github.com/aws/amazon-q-developer-cli) | REST API | Use via Q Developer custom tools |
| [Zed](https://zed.dev) | REST API | Integrate via Zed assistant context |
| Any agent | REST API | 109 endpoints — drop-in with any HTTP client |
### REST API Server
Run `python -m semantica.server` (or `python -m semantica`) — FastAPI on port 8000 with the following route groups:
| Route group | Module | Endpoints |
|---|---|---|
| `/api/graph` | `routes/graph.py` | Nodes, edges, traversal, graph topology |
| `/api/analytics` | `routes/analytics.py` | Centrality, communities, metrics |
| `/api/decisions` | `routes/decisions.py` | Decision CRUD, precedent search, causal chains |
| `/api/temporal` | `routes/temporal.py` | Point-in-time queries, snapshots, timelines |
| `/api/export` | `routes/export_import.py` | Import/export in RDF, Parquet, JSON, CSV, GraphML |
| `/api/annotations` | `routes/annotations.py` | Entity and edge annotation |
| `/api/enrich` | `routes/enrich.py` | Graph enrichment — embeddings, vectors, metadata |
| `/api/sparql` | `routes/sparql.py` | SPARQL query execution |
| `/api/provenance` | `routes/provenance.py` | Data lineage and audit trails |
| `/api/vocabulary` | `routes/vocabulary.py` | Ontology, SKOS concepts, schema definitions |
| `/ws` | `ws.py` | WebSocket — real-time graph mutation events |
| `/health` | `server.py` | Health check |
### Graph Databases
- **Neo4j** — Cypher queries via `semantica.graph_store`
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
@@ -974,6 +952,67 @@ if result.valid:
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
- **Ollama** — local models including remote server support
### Agentic Frameworks
Semantica complements — not replaces — every major agentic framework. Use it as the accountability layer on top.
<table>
<tr>
<td align="center" width="12.5%">
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="40" height="40" /></a><br/>
<strong>Agno</strong><br/>
<sub>First-class · <code>pip install semantica[agno]</code></sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="40" height="40" /></a><br/>
<strong>LangChain</strong><br/>
<sub>Context layer</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="40" height="40" /></a><br/>
<strong>LangGraph</strong><br/>
<sub>Stateful agent graph</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="40" height="40" /></a><br/>
<strong>LlamaIndex</strong><br/>
<sub>GraphRAG retriever</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="40" height="40" /></a><br/>
<strong>AutoGen</strong><br/>
<sub>Shared context graph</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="40" height="40" /></a><br/>
<strong>CrewAI</strong><br/>
<sub>Decision + provenance</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="40" height="40" /></a><br/>
<strong>OpenAI Agents</strong><br/>
<sub>Context + KG tools</sub>
</td>
<td align="center" width="12.5%">
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="40" height="40" /></a><br/>
<strong>Google ADK</strong><br/>
<sub>Context layer</sub>
</td>
</tr>
</table>
> **Agno — First-Class Integration** · `pip install semantica[agno]`
>
> Five 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 |
---
## 🛠️ Installation
+282
View File
@@ -0,0 +1,282 @@
# 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`.
- **1825% 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.
+105
View File
@@ -0,0 +1,105 @@
# 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)
+315 -857
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -8,8 +8,7 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -44,7 +43,6 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^5.4.0"
+3 -28
View File
@@ -15,7 +15,7 @@ const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/Enti
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 = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
@@ -311,39 +311,14 @@ function WorkspaceFallback() {
return <div className="workspace-loading">Loading workspace</div>;
}
function WelcomeScreen() {
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-muted)',
}}>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
Welcome to Semantica
</h1>
<p style={{ margin: 0, fontSize: 14 }}>
Select a workspace from the sidebar to get started.
</p>
</div>
);
}
export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const renderWorkspace = () => {
if (activeWorkspace === 'welcome') {
return <WelcomeScreen />;
}
if (activeWorkspace === 'explore') {
return (
<WorkspaceShell
@@ -476,7 +451,7 @@ export default function App() {
<style>{shellStyles}</style>
<div className="app-shell">
<aside className="app-rail">
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
{navItems.map(({ id, label, hint, icon: Icon }) => (
<button
key={id}
-10
View File
@@ -42,10 +42,6 @@ export interface NodeAttributes {
haloColor?: string;
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
highlighted?: boolean;
communityId?: string;
isCommunityGroup?: boolean;
memberCount?: number;
anchorNodeId?: string | null;
nodeType: string;
content: string;
@@ -78,12 +74,6 @@ export interface EdgeAttributes {
parallelIndex?: number;
parallelCount?: number;
familySize?: number;
rawEdgeIds?: string[];
isAggregated?: boolean;
aggregateCount?: number;
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
edgeType: string;
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@ import type { CSSProperties } from "react";
import { Loader2 } from "lucide-react";
import { graph } from "../../store/graphStore";
import { GRAPH_THEME } from "./graphTheme";
import type { GraphSelectedNodeKind } from "./types";
export type LinkPrediction = {
target: string;
@@ -15,16 +14,10 @@ export type PathResponse = {
path: string[];
edge_ids?: string[];
total_weight: number;
hop_count: number;
distance_band: "direct" | "near" | "mid-range" | "distant";
};
export interface GraphInspectorPanelProps {
nodeId: string;
inspectableNodeId?: string | null;
selectedNodeKind?: GraphSelectedNodeKind;
canActivateFocused?: boolean;
focusedUnavailableReason?: string | null;
predictions: LinkPrediction[];
predictionType: string;
onPredictionTypeChange: (value: string) => void;
@@ -153,10 +146,6 @@ function PathFlowViz({
export function GraphInspectorPanel({
nodeId,
inspectableNodeId,
selectedNodeKind = "none",
canActivateFocused = false,
focusedUnavailableReason = null,
predictions,
predictionType,
onPredictionTypeChange,
@@ -182,38 +171,7 @@ export function GraphInspectorPanel({
);
}
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
const directlyInspectable = graph.hasNode(nodeId);
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
if (!effectiveNodeId) {
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{nodeId}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
</div>
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? "Activate Focused mode to resolve this grouped selection to its canonical node."
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
</div>
</div>
</aside>
);
}
const attributes = graph.getNodeAttributes(effectiveNodeId) as {
const attributes = graph.getNodeAttributes(nodeId) as {
color?: string;
content?: string;
label?: string;
@@ -236,26 +194,12 @@ export function GraphInspectorPanel({
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
</span>
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{String(attributes?.label ?? effectiveNodeId)}
{String(attributes?.label ?? nodeId)}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
{groupedDisplaySelection ? nodeId : effectiveNodeId}
</div>
{groupedDisplaySelection ? (
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? `Canonical node available: ${effectiveNodeId}`
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
</div>
</div>
) : null}
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
{attributes?.valid_from || attributes?.valid_until ? (
<span style={subtleChipStyle}>temporal</span>
@@ -280,7 +224,7 @@ export function GraphInspectorPanel({
<button
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
onClick={onRunPredictions}
disabled={isRunningPredictions || !actionNodeId}
disabled={isRunningPredictions}
>
{isRunningPredictions ? (
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
@@ -288,10 +232,10 @@ export function GraphInspectorPanel({
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
</button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
Provenance JSON
</button>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
Provenance MD
</button>
</div>
@@ -313,7 +257,7 @@ export function GraphInspectorPanel({
placeholder="Target node ID"
style={inputStyle}
/>
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
{pathResult?.path?.length ? (
<PathFlowViz
@@ -432,14 +376,6 @@ const inputStyle: CSSProperties = {
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
};
const groupedSelectionNoticeStyle: CSSProperties = {
marginTop: 12,
padding: "10px 12px",
background: "rgba(88,166,255,0.08)",
border: "1px solid rgba(88,166,255,0.2)",
borderRadius: 12,
};
const actionButtonStyle: CSSProperties = {
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
color: "#fff",
@@ -3,7 +3,6 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
import { createGraphLoadProgress } from "./graphLoading";
import { resolveDisplayGraph } from "./graphSceneState";
import {
chooseColorAccessor,
colorForNodeKey,
@@ -42,7 +41,6 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
lensMode: "neighborhood",
effectQuality: "bounded",
};
const EMPTY_PATH: string[] = [];
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
@@ -69,10 +67,6 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
valid_until: attributes.valid_until ?? null,
properties: attributes.properties ?? {},
neighborCount: graph.neighbors(nodeId).length,
visibleNeighborCount: graph.neighbors(nodeId).length,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
};
}
@@ -119,10 +113,6 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const [graphVersion, setGraphVersion] = useState(0);
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
const displayResult = useMemo(
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
[activePath, graphVersion, selectedNodeId, viewMode],
);
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
@@ -457,16 +447,9 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
<SigmaSceneAdapter
ref={sceneRef}
onNodeSelect={onNodeSelect}
graphVersion={graphVersion}
graphReady={Boolean(snapshot)}
displayGraph={displayResult.graph}
displayMeta={displayResult.meta}
displayState={displayResult.state}
selectedEdgeId=""
selectedNodeId={selectedNodeId}
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
activePath={activePath}
activePathEdgeIds={EMPTY_PATH}
effectsState={STAGE_EFFECTS_STATE}
isLayoutRunning={isLayoutRunning}
onLayoutRunningChange={onLayoutRunningChange}
@@ -1,6 +1,5 @@
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";
@@ -12,7 +11,6 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
import {
type GraphPlugin,
type GraphPluginActionRequest,
@@ -25,7 +23,6 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
GraphAnalyticsSnapshot,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectToggle,
GraphEffectsState,
@@ -33,7 +30,6 @@ import type {
GraphLoadProgress,
GraphLoadSummary,
GraphSelectedEdgeState,
GraphSelectedNodeKind,
GraphSelectedNodeState,
GraphTemporalState,
GraphViewMode,
@@ -93,7 +89,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
pathFlowEnabled: false,
lensEnabled: false,
temporalEmphasisEnabled: false,
semanticRegionsEnabled: false,
semanticRegionsEnabled: true,
contoursEnabled: false,
pathfindingEnabled: false,
communitiesEnabled: false,
@@ -109,15 +105,6 @@ const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then(
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
const EMPTY_PATH: string[] = [];
const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV;
function debugGraphWorkspace(message: string, payload?: Record<string, unknown>) {
if (!DEBUG_GRAPH_WORKSPACE) {
return;
}
console.debug(`[GraphWorkspace] ${message}`, payload ?? {});
}
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
@@ -507,10 +494,7 @@ function buildRealtimeEdgeAttributes(payload: {
};
}
function buildSelectedNodeState(
nodeId: string,
displayState: GraphDisplayStateSnapshot,
): GraphSelectedNodeState | null {
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
if (!nodeId || !graph.hasNode(nodeId)) {
return null;
}
@@ -535,64 +519,31 @@ function buildSelectedNodeState(
valid_until: attributes.valid_until ?? null,
properties: attributes.properties ?? {},
neighborCount: graph.neighbors(nodeId).length,
visibleNeighborCount: displayState.selectedVisibleNeighborIds.length,
collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length,
isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0,
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
};
}
type FocusResolution = {
kind: GraphSelectedNodeKind;
resolvedNodeId: string | null;
reason: string | null;
};
function buildSelectedEdgeState(
edgeId: string,
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
): GraphSelectedEdgeState | null {
if (!edgeId || !displayGraph.hasEdge(edgeId)) {
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
if (!edgeId || !graph.hasEdge(edgeId)) {
return null;
}
const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
const attributes = displayGraph.getEdgeAttributes(edgeId) as {
const [sourceId, targetId] = graph.extremities(edgeId);
const attributes = graph.getEdgeAttributes(edgeId) as {
edgeType?: string;
weight?: number;
properties?: Record<string, unknown>;
familyId?: string;
rawEdgeIds?: string[];
isAggregated?: boolean;
aggregateCount?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
dominantEdgeType?: string;
representativeWeight?: number;
};
const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId];
const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0];
let sourceId = displaySourceId;
let targetId = displayTargetId;
if (primaryRawEdgeId && graph.hasEdge(primaryRawEdgeId)) {
[sourceId, targetId] = graph.extremities(primaryRawEdgeId);
}
const sourceAttributes = graph.hasNode(sourceId)
? (graph.getNodeAttributes(sourceId) as { label?: string; content?: string })
: ({ label: displaySourceId } as { label?: string; content?: string });
const targetAttributes = graph.hasNode(targetId)
? (graph.getNodeAttributes(targetId) as { label?: string; content?: string })
: ({ label: displayTargetId } as { label?: string; content?: string });
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
const properties = attributes.properties ?? {};
const familyId = String(attributes.familyId || edgeId);
let familySize = 0;
let siblingCount = 0;
rawEdgeIds.forEach((rawEdgeId) => {
if (!graph.hasEdge(rawEdgeId)) {
return;
}
const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string };
const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId);
if (String(candidateAttrs.familyId || rawEdgeId) === familyId) {
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
const edgeAttrs = candidateAttrs as { familyId?: string };
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
familySize += 1;
}
if (candidateSource === sourceId && candidateTarget === targetId) {
@@ -600,11 +551,6 @@ function buildSelectedEdgeState(
}
});
if (attributes.isAggregated) {
siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
familySize = Math.max(familySize, siblingCount);
}
return {
id: edgeId,
familyId,
@@ -618,12 +564,6 @@ function buildSelectedEdgeState(
provenanceCount: getProvenanceCount(properties),
familySize,
siblingCount,
isAggregated: Boolean(attributes.isAggregated),
aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length),
rawEdgeIds,
bundleKind: attributes.bundleKind ?? null,
dominantEdgeType: attributes.dominantEdgeType ?? null,
representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1),
};
}
@@ -684,15 +624,9 @@ function collectPluginOverlays(
export function GraphWorkspace() {
const [selectedNodeId, setSelectedNodeId] = useState("");
const [focusedNodeId, setFocusedNodeId] = useState("");
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
const [selectedEdgeId, setSelectedEdgeId] = useState("");
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [graphReady, setGraphReady] = useState(false);
const [graphVersion, setGraphVersion] = useState(0);
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
const [aggregationEnabled] = useState(true);
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchError, setSearchError] = useState("");
@@ -729,24 +663,15 @@ export function GraphWorkspace() {
focusedNodeId: "",
activePath: [],
activePathEdgeIds: [],
viewMode: "full",
viewMode: "focused",
zoomTier: "overview",
isLayoutRunning: false,
});
const reload = useReloadGraph();
const handleLoadProgress = useCallback((progress: GraphLoadProgress) => {
setLoadingProgress(progress);
if (progress.phase !== "ready" && progress.phase !== "stabilizing_layout") {
setGraphReady(false);
}
}, []);
const { data: summary, isLoading, isFetching } = useLoadGraph({
enabled: true,
onGraphReady: (graphSummary) => {
setGraphReady(true);
setGraphVersion((current) => current + 1);
setIsLayoutRunning(!graphSummary.layoutReady);
if (settlingOverlayTimeoutRef.current !== null) {
window.clearTimeout(settlingOverlayTimeoutRef.current);
@@ -775,7 +700,7 @@ export function GraphWorkspace() {
settlingOverlayTimeoutRef.current = null;
}, 900);
},
onProgress: handleLoadProgress,
onProgress: setLoadingProgress,
});
useEffect(() => {
@@ -837,7 +762,6 @@ export function GraphWorkspace() {
});
prevActiveIdsRef.current = nextActiveIds;
setActiveNodeCount(data.active_node_count);
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
});
} catch (fetchError) {
@@ -853,166 +777,16 @@ export function GraphWorkspace() {
};
}, [debouncedTime, isLoading]);
const resolveNodeIdForFocusedMode = useCallback((
nodeId: string,
displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null,
): FocusResolution => {
if (!nodeId) {
return {
kind: "none",
resolvedNodeId: null,
reason: "Select a node to inspect in Focused mode.",
};
}
if (graph.hasNode(nodeId)) {
return {
kind: "base",
resolvedNodeId: nodeId,
reason: null,
};
}
const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph;
if (currentDisplayGraph.hasNode(nodeId)) {
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
const communityGroup = displayAttrs.properties?.__communityGroup as
| {
anchorNodeId?: string | null;
sampleNodeIds?: string[];
}
| undefined;
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
return {
kind: "grouped",
resolvedNodeId: anchorNodeId,
reason: null,
};
}
return {
kind: "grouped",
resolvedNodeId: null,
reason: "Focused mode is unavailable for this grouped selection.",
};
}
return {
kind: "unavailable",
resolvedNodeId: null,
reason: "Selected item is not available in the current graph.",
};
}, []);
const focusedSelectionResolution = useMemo(
() => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph),
[pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode],
);
const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? "";
const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId);
const { available: groupedViewAvailable, reason: groupedViewReason } = useMemo(
() => checkGroupedViewAvailability(),
[graphVersion],
);
const groupedDisplayCandidate = useMemo(
() => viewMode === "grouped"
? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
})
: null,
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
);
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
if (nextViewMode === "focused") {
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
if (!resolution.resolvedNodeId) {
return;
}
setFocusedNodeId(resolution.resolvedNodeId);
setSelectedNodeId(resolution.resolvedNodeId);
setViewMode("focused");
setIsLayoutRunning(false);
return;
}
if (nextViewMode === "grouped") {
if (!groupedViewAvailable) {
debugGraphWorkspace("grouped-view-unavailable", {
reason: groupedViewReason,
graphVersion,
});
return;
}
const groupedDisplayGraph = groupedDisplayCandidate?.graph
?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
}).graph;
const nextGroupedSelection = [
lastGroupedSelectedNodeId,
selectedNodeId,
focusedNodeId,
]
.map((candidateId) => resolveGroupedDisplayNodeId(groupedDisplayGraph, candidateId))
.find((candidateId): candidateId is string => Boolean(candidateId))
?? "";
setFocusedNodeId("");
setSelectedNodeId(nextGroupedSelection);
if (nextGroupedSelection) {
setLastGroupedSelectedNodeId(nextGroupedSelection);
}
setViewMode("grouped");
setIsLayoutRunning(true);
return;
}
setFocusedNodeId("");
setSelectedNodeId((currentSelectedNodeId) => (
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
));
setViewMode("full");
}, [
aggregationEnabled,
collapsedNeighborhoodNodeIds,
focusedNodeId,
graphVersion,
groupedDisplayCandidate,
groupedViewAvailable,
groupedViewReason,
lastGroupedSelectedNodeId,
resolveNodeIdForFocusedMode,
selectedNodeId,
]);
const focusNode = useCallback((nodeId: string) => {
if (!nodeId) {
return;
}
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
const nextSelectedNodeId = nodeId;
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
setLastGroupedSelectedNodeId(nodeId);
}
setSelectedNodeId(nextSelectedNodeId);
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
setPathResult(null);
setSearchResults([]);
setSearchError("");
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
setFocusedNodeId(nextSelectedNodeId);
if (nodeId) {
setIsLayoutRunning(false);
}
}, [viewMode]);
}, []);
const handleEdgeSelect = useCallback((edgeId: string) => {
setSelectedEdgeId(edgeId);
@@ -1041,14 +815,14 @@ export function GraphWorkspace() {
}, [searchQuery]);
const handleRunPredictions = useCallback(async () => {
if (!inspectableNodeId) return;
if (!selectedNodeId) return;
setIsRunningPredictions(true);
try {
const response = await fetch("/api/enrich/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
node_id: inspectableNodeId,
node_id: selectedNodeId,
top_n: 6,
candidate_type: predictionType || undefined,
min_score: 0,
@@ -1065,13 +839,13 @@ export function GraphWorkspace() {
} finally {
setIsRunningPredictions(false);
}
}, [inspectableNodeId, predictionType]);
}, [predictionType, selectedNodeId]);
const handleTracePath = useCallback(async () => {
if (!inspectableNodeId || !pathTargetId.trim()) return;
if (!selectedNodeId || !pathTargetId.trim()) return;
try {
const response = await fetch(
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
);
if (!response.ok) {
throw new Error(`Path lookup failed with status ${response.status}`);
@@ -1088,12 +862,12 @@ export function GraphWorkspace() {
console.error("[GraphWorkspace] path trace failed", pathError);
setPathResult(null);
}
}, [inspectableNodeId, pathTargetId]);
}, [pathTargetId, selectedNodeId]);
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
if (!inspectableNodeId) return;
if (!selectedNodeId) return;
const suffix = format === "markdown" ? "markdown" : "json";
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
if (!response.ok) {
throw new Error(`Provenance report failed with status ${response.status}`);
}
@@ -1101,12 +875,12 @@ export function GraphWorkspace() {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
}, [inspectableNodeId]);
}, [selectedNodeId]);
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -1131,7 +905,6 @@ export function GraphWorkspace() {
},
]);
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
if (eventType === "ADD_EDGE") {
@@ -1145,7 +918,6 @@ export function GraphWorkspace() {
},
]);
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 });
setGraphVersion((current) => current + 1);
sceneRef.current?.getRuntime()?.requestRender();
}
} catch (socketError) {
@@ -1158,163 +930,32 @@ export function GraphWorkspace() {
};
}, []);
useEffect(() => {
setCollapsedNeighborhoodNodeIds([]);
setFocusedNodeId("");
setLastGroupedSelectedNodeId("");
}, [summary?.edgeCount, summary?.nodeCount]);
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
const hasGraphContent = Boolean(summary?.nodeCount);
const activePath = pathResult?.path ?? EMPTY_PATH;
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
const structuralSelectedNodeId = useMemo(() => {
if (viewMode === "focused") {
return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
}
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
return "";
}
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
const displayResult = useMemo(
() => (
viewMode === "grouped"
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
}))
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
})
),
[
aggregationEnabled,
collapsedNeighborhoodNodeIds,
groupedDisplayCandidate,
structuralActivePath,
structuralActivePathEdgeIds,
structuralSelectedNodeId,
viewMode,
],
);
const displayState = useMemo(
() => (
viewMode === "grouped"
? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, {
groupedViewAvailable,
groupedViewReason,
selectedNodeKind: focusedSelectionResolution.kind,
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
focusedUnavailableReason: focusedSelectionResolution.reason,
})
: resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
groupedViewAvailable,
groupedViewReason,
selectedNodeKind: focusedSelectionResolution.kind,
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
focusedUnavailableReason: focusedSelectionResolution.reason,
})
),
[
activePath,
aggregationEnabled,
collapsedNeighborhoodNodeIds,
displayResult.graph,
focusedSelectionResolution.kind,
focusedSelectionResolution.reason,
focusedSelectionResolution.resolvedNodeId,
groupedViewAvailable,
groupedViewReason,
selectedNodeId,
viewMode,
],
);
const displayMeta = displayResult.meta;
useEffect(() => {
if (viewMode === "grouped" && !groupedViewAvailable) {
debugGraphWorkspace("grouped-view-reset-to-full", {
reason: groupedViewReason,
graphVersion,
});
setViewMode("full");
setFocusedNodeId("");
setSelectedNodeId((currentSelectedNodeId) => (
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
));
}
}, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]);
useEffect(() => {
if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) {
setViewMode("full");
setFocusedNodeId("");
}
}, [focusedNodeId, graphVersion, viewMode]);
const previousDisplayGraphRef = useRef(displayResult.graph);
const previousDisplayStateRef = useRef(displayState);
useEffect(() => {
const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph;
const displayStateChanged = previousDisplayStateRef.current !== displayState;
debugGraphWorkspace("display-state-derived", {
selectedNodeId,
structuralSelectedNodeId,
viewMode,
graphRebuilt,
displayStateChanged,
aggregationEnabled,
collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)),
});
previousDisplayGraphRef.current = displayResult.graph;
previousDisplayStateRef.current = displayState;
}, [
aggregationEnabled,
collapsedNeighborhoodNodeIds,
displayResult.graph,
displayState,
selectedNodeId,
structuralSelectedNodeId,
viewMode,
]);
const focusedSummary = useMemo(() => {
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
if (viewMode === "grouped") {
return displayState.groupedViewAvailable
? "Communities compressed into grouped structure view"
: (displayState.groupedViewReason ?? "Grouped view is unavailable for the current graph");
}
return null;
}
const localNeighborCount = graph.neighbors(selectedNodeId).length;
if (viewMode === "focused") {
const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
const visibleNeighbors = Math.min(localNeighborCount, 16);
return `${visibleNeighbors + 1} nodes in focused view`;
}
if (viewMode === "grouped") {
return "Grouped structure view with direct community drill-in";
}
if (displayState.selectedCollapsedNeighborIds.length > 0) {
return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
}
return `${localNeighborCount} direct neighbors highlighted`;
}, [displayState, selectedNodeId, viewMode]);
}, [selectedNodeId, viewMode]);
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
const hasGraphContent = Boolean(summary?.nodeCount);
const activePath = pathResult?.path ?? [];
const activePathEdgeIds = pathResult?.edge_ids ?? [];
const graphSummary = summary as GraphLoadSummary | null;
const selectedNodeState = useMemo(
() => buildSelectedNodeState(selectedNodeId, displayState),
[displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
() => buildSelectedNodeState(selectedNodeId),
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
);
const selectedEdgeState = useMemo(
() => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
[displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
() => buildSelectedEdgeState(selectedEdgeId),
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
);
const temporalState = useMemo(
() => ({
@@ -1420,21 +1061,7 @@ export function GraphWorkspace() {
focusNode(action.nodeId);
return;
case "setViewMode":
requestViewMode(action.viewMode);
return;
case "collapseNeighborhood":
if (!selectedNodeId) {
return;
}
setCollapsedNeighborhoodNodeIds((current) => (
current.includes(selectedNodeId) ? current : [...current, selectedNodeId]
));
return;
case "expandNeighborhood":
if (!selectedNodeId) {
return;
}
setCollapsedNeighborhoodNodeIds((current) => current.filter((nodeId) => nodeId !== selectedNodeId));
setViewMode(action.viewMode);
return;
case "toggleEffect":
setEffectToggle(action.effect, (current) => !current);
@@ -1472,7 +1099,7 @@ export function GraphWorkspace() {
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
return;
}
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
}, [focusNode, setEffectToggle]);
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
@@ -1512,11 +1139,9 @@ export function GraphWorkspace() {
getEffectsState: () => effectsState,
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
getAnalyticsSnapshot: () => graphAnalyticsState,
getDisplayState: () => displayState,
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
dispatchAction: handlePluginAction,
}), [
displayState,
graphAnalyticsState,
diagnosticsSnapshot,
effectsState,
@@ -1642,58 +1267,23 @@ export function GraphWorkspace() {
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
const groups: GraphToolbarGroup[] = [];
if (hasGraphContent) {
if (selectedNodeId) {
groups.push({
id: "view-mode",
items: [
{
id: "view-focused",
label: "Focused",
title: "Inspect the selected node in a focused local graph",
active: viewMode === "focused",
onClick: () => setViewMode("focused"),
},
{
id: "view-full",
label: "Full Graph",
title: "Return to the full graph context",
active: viewMode === "full",
onClick: () => requestViewMode("full"),
},
{
id: "view-grouped",
label: "Grouped View",
title: displayState.groupedViewAvailable
? "Compress dense structure into detected communities"
: (displayState.groupedViewReason ?? "Grouped view is unavailable until communities can be detected"),
active: viewMode === "grouped",
disabled: !displayState.groupedViewAvailable,
onClick: () => requestViewMode("grouped"),
},
{
id: "view-focused",
label: "Focused",
title: canActivateFocusedMode
? "Inspect the selected node in a focused local graph"
: (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"),
active: viewMode === "focused",
disabled: viewMode !== "focused" && !canActivateFocusedMode,
onClick: () => requestViewMode("focused"),
},
],
});
}
if (selectedNodeState) {
groups.push({
id: "local-structure",
items: [
{
id: "collapse-neighborhood",
label: "Collapse Neighborhood",
title: "Hide lower-priority fanout around the selected node",
disabled: !selectedNodeState.canCollapseNeighborhood || selectedNodeState.isNeighborhoodCollapsed,
onClick: () => handlePluginAction({ type: "collapseNeighborhood" }),
},
{
id: "expand-neighborhood",
label: "Expand Neighborhood",
title: "Restore the collapsed local neighborhood",
disabled: !selectedNodeState.isNeighborhoodCollapsed,
onClick: () => handlePluginAction({ type: "expandNeighborhood" }),
onClick: () => setViewMode("full"),
},
],
});
@@ -1714,13 +1304,25 @@ export function GraphWorkspace() {
id: "zoom-in",
label: " Zoom In",
title: "Zoom in (or scroll up on the canvas)",
onClick: () => sceneRef.current?.zoomIn(),
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: () => sceneRef.current?.zoomOut(),
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",
@@ -1760,42 +1362,18 @@ export function GraphWorkspace() {
}
return groups;
}, [
canActivateFocusedMode,
displayState.groupedViewAvailable,
displayState.groupedViewReason,
handlePluginAction,
hasGraphContent,
isLayoutRunning,
pluginToolbarItems,
reload,
requestViewMode,
searchQuery,
canActivateFocusedMode,
focusedSelectionResolution.reason,
selectedNodeId,
selectedNodeState,
showLoadingOverlay,
viewMode,
]);
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
const sceneAdapterProps = {
onNodeSelect: focusNode,
onEdgeSelect: handleEdgeSelect,
graphVersion,
graphReady,
displayGraph: displayResult.graph,
displayMeta,
displayState,
selectedNodeId,
focusedNodeId,
selectedEdgeId,
activePath,
activePathEdgeIds,
effectsState,
temporalState,
isLayoutRunning,
layoutSource: graphSummary?.layoutSource,
viewMode,
showFitViewButton: false,
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
@@ -1817,7 +1395,7 @@ export function GraphWorkspace() {
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="explore-toolbar">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
{showLoadingOverlay && loadingProgress ? (
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
) : null}
{summary ? (
@@ -1926,20 +1504,8 @@ export function GraphWorkspace() {
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
{selectedEdgeState.isAggregated ? (
<MetricChip tone="success">
{selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"}
</MetricChip>
) : (
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
)}
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
<MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
{selectedEdgeState.bundleKind ? (
<MetricChip>{selectedEdgeState.bundleKind} bundle</MetricChip>
) : null}
{selectedEdgeState.dominantEdgeType ? (
<MetricChip>{selectedEdgeState.dominantEdgeType}</MetricChip>
) : null}
{selectedEdgeState.provenanceCount > 0 ? (
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
) : null}
@@ -2042,10 +1608,6 @@ export function GraphWorkspace() {
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector</div>}>
<LazyGraphInspectorPanel
nodeId={selectedNodeId}
inspectableNodeId={inspectableNodeId || null}
selectedNodeKind={displayState.selectedNodeKind}
canActivateFocused={canActivateFocusedMode}
focusedUnavailableReason={displayState.focusedUnavailableReason}
predictions={predictions}
predictionType={predictionType}
onPredictionTypeChange={setPredictionType}
@@ -33,8 +33,6 @@ type LinkPrediction = {
type PathResponse = {
path: GraphPath;
total_weight: number;
hop_count: number;
distance_band: "direct" | "near" | "mid-range" | "distant";
};
type TemporalBounds = {
@@ -139,10 +137,6 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
valid_until: node.valid_until ?? null,
properties: node.properties ?? {},
neighborCount,
visibleNeighborCount: neighborCount,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: neighborCount > 8,
};
}
@@ -431,10 +425,6 @@ export function GraphWorkspaceShell() {
valid_until: null,
properties: searchNode.properties ?? {},
neighborCount: 0,
visibleNeighborCount: 0,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: false,
}
: null;
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
@@ -563,19 +553,6 @@ export function GraphWorkspaceShell() {
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
}, [viewMode, visibleSelectedNode]);
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
if (nextViewMode === "focused") {
if (!selectedNodeId) {
return;
}
setViewMode("focused");
setIsLayoutRunning(false);
return;
}
setViewMode("full");
}, [selectedNodeId]);
const showLoadingOverlay =
isLoading
|| isFetching
@@ -652,8 +629,8 @@ export function GraphWorkspaceShell() {
<div className="graph-toggle-cluster">
{selectedNodeId ? (
<>
<button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
<button onClick={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
</>
) : (
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
@@ -24,8 +24,6 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
useImperativeHandle(ref, () => ({
fitView: () => canvasRef.current?.fitView(),
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
zoomIn: () => canvasRef.current?.zoomIn(),
zoomOut: () => canvasRef.current?.zoomOut(),
getRuntime: () => runtimeRef.current,
setLayoutRunning: onLayoutRunningChange
? (running: boolean) => {
@@ -5,21 +5,11 @@ export const focusCameraBehavior: GraphBehavior = {
attach: () => {},
detach: () => {},
performAction: (context, action) => {
if (action.type === "focusNode") {
context.focusNodeInView(action.nodeId);
return true;
if (action.type !== "focusNode") {
return false;
}
if (action.type === "centerSelection") {
context.centerSelectionInView(action.nodeId);
return true;
}
if (action.type === "centerGroupedSelection") {
context.centerGroupedSelectionInView(action.nodeId);
return true;
}
return false;
context.focusNodeInView(action.nodeId);
return true;
},
};
@@ -1,36 +1,23 @@
import type { GraphBehavior } from "./types";
export function createSearchFocusBehavior(): GraphBehavior {
let lastSelectedNodeId = "";
let lastViewMode = "";
let lastFocusedNodeId = "";
return {
id: "search-focus",
attach: () => {},
detach: () => {
lastSelectedNodeId = "";
lastViewMode = "";
lastFocusedNodeId = "";
},
onStateChange: (context, interactionState) => {
const nextSelectedNodeId = interactionState.selectedNodeId;
const nextViewMode = interactionState.viewMode;
if (nextViewMode !== lastViewMode) {
lastViewMode = nextViewMode;
lastSelectedNodeId = nextSelectedNodeId;
return;
}
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
lastSelectedNodeId = nextSelectedNodeId;
lastViewMode = nextViewMode;
const nextFocusedNodeId = interactionState.focusedNodeId;
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
lastFocusedNodeId = nextFocusedNodeId;
return;
}
lastSelectedNodeId = nextSelectedNodeId;
lastViewMode = nextViewMode;
context.dispatchAction({
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
nodeId: nextSelectedNodeId,
});
lastFocusedNodeId = nextFocusedNodeId;
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
},
};
}
@@ -6,9 +6,7 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
export type GraphBehaviorActionRequest =
| { type: "fitView" }
| { type: "focusNode"; nodeId: string }
| { type: "centerSelection"; nodeId: string }
| { type: "centerGroupedSelection"; nodeId: string };
| { type: "focusNode"; nodeId: string };
export interface GraphBehaviorContext {
sigma: Sigma;
@@ -19,8 +17,6 @@ export interface GraphBehaviorContext {
onNodeSelectionChange: (nodeId: string) => void;
onEdgeSelectionChange: (edgeId: string) => void;
focusNodeInView: (nodeId: string) => void;
centerSelectionInView: (nodeId: string) => void;
centerGroupedSelectionInView: (nodeId: string) => void;
fitCurrentView: () => void;
dispatchAction: (action: GraphBehaviorActionRequest) => void;
}
@@ -1,8 +1,7 @@
import type { GraphBehavior } from "./types";
import type { GraphViewMode } from "../types";
export function createViewModeSwitchBehavior(): GraphBehavior {
let lastViewMode: GraphViewMode | null = null;
let lastViewMode: "focused" | "full" | null = null;
return {
id: "view-mode-switch",
@@ -16,10 +15,9 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
}
lastViewMode = interactionState.viewMode;
const nextFocusedNodeId = interactionState.focusedNodeId;
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
if (interactionState.focusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
return;
}
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,6 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
export interface GraphTheme {
palette: {
@@ -191,35 +190,6 @@ export interface GraphTheme {
motion: {
cameraMs: number;
};
grouped: {
initialLayout: {
innerRadius: number;
ringSpacing: number;
minNodeSpacing: number;
nodePadding: number;
overlapIterations: number;
primaryLabelCount: number;
};
style: {
nodeSizeScale: number;
nodeBorderBoost: number;
fillAlpha: number;
shellAlpha: number;
edgeSizeScale: number;
edgeAlpha: number;
glowAlpha: number;
edgeVisibilityRatio: number;
topIncidentEdges: number;
};
layout: {
iterations: number;
gravity: number;
scalingRatio: number;
edgeWeightInfluence: number;
slowDown: number;
settleMs: number;
};
};
effects: {
pathPulse: {
minZoomTier: GraphZoomTier;
@@ -335,10 +305,10 @@ export const GRAPH_THEME: GraphTheme = {
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.72,
labelThreshold: 0.995,
labelBudget: 4,
edgePriorityThreshold: 0.72,
nodeScale: 0.88,
labelThreshold: 0.92,
labelBudget: 28,
edgePriorityThreshold: 0.55,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.62,
showBadges: false,
@@ -347,21 +317,21 @@ export const GRAPH_THEME: GraphTheme = {
},
structure: {
maxRatio: 1.2,
nodeScale: 0.94,
labelThreshold: 0.93,
labelBudget: 18,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
showBadges: false,
showCurves: false,
showContextualArrows: false,
nodeScale: 1.02,
labelThreshold: 0.82,
labelBudget: 60,
edgePriorityThreshold: 0.3,
arrowPriorityThreshold: 0.65,
edgeSizeScale: 1.05,
showBadges: true,
showCurves: true,
showContextualArrows: true,
},
inspection: {
maxRatio: 0.5,
nodeScale: 1,
labelThreshold: 0.8,
labelBudget: 40,
nodeScale: 1.08,
labelThreshold: 0.6,
labelBudget: 120,
edgePriorityThreshold: 0,
arrowPriorityThreshold: 0.45,
edgeSizeScale: 1.18,
@@ -371,7 +341,7 @@ export const GRAPH_THEME: GraphTheme = {
},
},
labels: {
forceVisibleStates: ["hovered", "selected", "path"],
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
policies: {
none: { minZoomTier: "inspection" },
priority: { minZoomTier: "overview" },
@@ -421,26 +391,26 @@ export const GRAPH_THEME: GraphTheme = {
},
nodes: {
backgroundScale: 0.52,
mutedAlpha: 0.16,
mutedAlpha: 0.08,
strokeHierarchy: {
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
},
variants: {
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
},
selectedRing: {
@@ -503,35 +473,6 @@ export const GRAPH_THEME: GraphTheme = {
motion: {
cameraMs: 380,
},
grouped: {
initialLayout: {
innerRadius: 92,
ringSpacing: 138,
minNodeSpacing: 112,
nodePadding: 28,
overlapIterations: 18,
primaryLabelCount: 6,
},
style: {
nodeSizeScale: 0.9,
nodeBorderBoost: 0.42,
fillAlpha: 0.68,
shellAlpha: 0.28,
edgeSizeScale: 0.62,
edgeAlpha: 0.32,
glowAlpha: 0.14,
edgeVisibilityRatio: 0.18,
topIncidentEdges: 2,
},
layout: {
iterations: 18,
gravity: 0.06,
scalingRatio: 18,
edgeWeightInfluence: 0.08,
slowDown: 34,
settleMs: 1500,
},
},
effects: {
pathPulse: {
minZoomTier: "structure",
@@ -589,7 +530,7 @@ export const GRAPH_THEME: GraphTheme = {
maxGroups: 8,
},
diagnostics: {
enabledInDev: IS_DEV,
enabledInDev: import.meta.env.DEV,
},
},
};
@@ -42,7 +42,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
}
const selected = context.getSelectedNodeState();
const displayState = context.getDisplayState();
if (!selected) {
return {
id: NEIGHBORHOOD_PANEL_ID,
@@ -83,11 +82,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
return left.label.localeCompare(right.label);
})
.slice(0, MAX_NEIGHBORS);
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
const aggregatedEdgeCount = context.displayGraph
.edges()
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
.filter((attrs) => attrs.isAggregated).length;
return {
id: NEIGHBORHOOD_PANEL_ID,
@@ -103,34 +97,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
<div style={summaryStyle}>
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Collapse Neighborhood
</button>
<button
type="button"
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
disabled={!selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Expand Neighborhood
</button>
</div>
{hiddenNeighborCount > 0 ? (
<div style={summaryStyle}>
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
</div>
) : null}
{aggregatedEdgeCount > 0 ? (
<div style={summaryStyle}>
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
</div>
) : null}
{neighbors.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{neighbors.map((neighbor) => (
@@ -193,16 +159,6 @@ const neighborButtonStyle: CSSProperties = {
cursor: "pointer",
};
const controlButtonStyle: CSSProperties = {
padding: "7px 10px",
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
color: "#dce7f4",
cursor: "pointer",
fontSize: 12,
};
const swatchStyle: CSSProperties = {
width: 10,
height: 10,
@@ -6,7 +6,6 @@ import type { GraphTheme } from "../graphTheme";
import type { GraphSceneRuntime } from "../scene";
import type {
GraphAnalyticsSnapshot,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphEffectToggle,
@@ -32,8 +31,6 @@ export type GraphPluginActionRequest =
| { type: "focusNode"; nodeId: string }
| { type: "selectNode"; nodeId: string }
| { type: "setViewMode"; viewMode: GraphViewMode }
| { type: "collapseNeighborhood" }
| { type: "expandNeighborhood" }
| { type: "toggleEffect"; effect: GraphEffectToggle }
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
| { type: "togglePanel"; panelId: string }
@@ -80,7 +77,6 @@ export interface GraphPluginContext {
getEffectsState: () => GraphEffectsState;
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
getDisplayState: () => GraphDisplayStateSnapshot;
isPanelOpen: (panelId: string) => boolean;
dispatchAction: (action: GraphPluginActionRequest) => void;
}
@@ -5,8 +5,6 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
@@ -24,8 +22,6 @@ export interface GraphSceneRuntime {
scene: unknown;
graph: GraphSceneGraph;
displayGraph: GraphSceneGraph;
graphVersion: number;
layoutMode?: GraphDisplayMeta["layoutMode"];
requestRender: () => void;
getCameraState: () => GraphCameraState | null;
}
@@ -41,13 +37,7 @@ export interface GraphSceneEventMap {
}
export interface GraphSceneProps extends GraphSceneEventMap {
graphVersion: number;
graphReady: boolean;
displayGraph: GraphSceneGraph;
displayMeta: GraphDisplayMeta;
displayState?: GraphDisplayStateSnapshot;
selectedNodeId: string;
focusedNodeId: string;
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
@@ -66,8 +56,6 @@ export interface GraphSceneProps extends GraphSceneEventMap {
export interface GraphSceneHandle {
fitView: () => void;
focusNode: (nodeId: string) => void;
zoomIn: () => void;
zoomOut: () => void;
getRuntime: () => GraphSceneRuntime | null;
setLayoutRunning?: (running: boolean) => void;
}
@@ -1,4 +1,4 @@
export type GraphViewMode = "focused" | "full" | "grouped";
export type GraphViewMode = "focused" | "full";
export type GraphLayoutSource = "provided" | "carried" | "runtime";
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
export type GraphLoadPhase =
@@ -12,7 +12,6 @@ export type GraphLoadPhase =
export type GraphLoadProgressKind = "determinate" | "indeterminate";
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
export interface GraphCameraState {
x: number;
@@ -32,28 +31,6 @@ export interface GraphInteractionState {
isLayoutRunning: boolean;
}
export interface GraphDisplayStateSnapshot {
aggregationEnabled: boolean;
groupedViewAvailable: boolean;
groupedViewReason: string | null;
selectedRootNodeId: string | null;
selectedVisibleNeighborIds: string[];
selectedCollapsedNeighborIds: string[];
selectedNodeKind: GraphSelectedNodeKind;
canActivateFocused: boolean;
resolvedFocusedNodeId: string | null;
focusedUnavailableReason: string | null;
}
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
export interface GraphDisplayMeta {
layoutMode: GraphDisplayLayoutMode;
positionSource: "store" | "display";
tracksStoreNodePositions: boolean;
hasSyntheticNodes: boolean;
}
export type GraphEffectToggle =
| "pathPulseEnabled"
| "pathFlowEnabled"
@@ -268,10 +245,6 @@ export interface GraphSelectedNodeState {
valid_until?: string | null;
properties: Record<string, unknown>;
neighborCount: number;
visibleNeighborCount: number;
collapsedNeighborCount: number;
isNeighborhoodCollapsed: boolean;
canCollapseNeighborhood: boolean;
}
export interface GraphSelectedEdgeState {
@@ -287,12 +260,6 @@ export interface GraphSelectedEdgeState {
provenanceCount: number;
familySize: number;
siblingCount: number;
isAggregated: boolean;
aggregateCount: number;
rawEdgeIds: string[];
bundleKind: "parallel" | "bidirectional" | "community" | null;
dominantEdgeType: string | null;
representativeWeight: number;
}
export interface GraphStageHandle {
@@ -1,259 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
batchMergeEdges,
batchMergeNodes,
clearGraph,
} from "../src/store/graphStore.ts";
import {
checkGroupedViewAvailability,
resolveDisplayGraph,
resolveGroupedDisplayNodeId,
resolveGroupedDisplayStateSnapshot,
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
function addNode(id: string, semanticGroup = "entity") {
batchMergeNodes([
{
id,
attributes: {
label: id,
content: id,
x: 0,
y: 0,
size: 8,
color: "#63E6FF",
baseColor: "#63E6FF",
nodeType: semanticGroup,
semanticGroup,
properties: {},
},
},
]);
}
function addEdge(id: string, source: string, target: string, weight = 1) {
batchMergeEdges([
{
id,
source,
target,
attributes: {
edgeType: "related_to",
weight,
properties: {},
},
},
]);
}
test.beforeEach(() => {
clearGraph();
});
test.after(() => {
clearGraph();
});
test("resolveDisplayGraph bundles parallel edges in full view", () => {
addNode("a");
addNode("b");
addEdge("e1", "a", "b", 1);
addEdge("e2", "a", "b", 2);
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
assert.equal(graph.size, 1);
const edgeId = graph.edges()[0];
const attrs = graph.getEdgeAttributes(edgeId) as {
isAggregated?: boolean;
aggregateCount?: number;
rawEdgeIds?: string[];
bundleKind?: string;
};
assert.equal(attrs.isAggregated, true);
assert.equal(attrs.aggregateCount, 2);
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
assert.equal(attrs.bundleKind, "parallel");
});
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
addNode("center");
for (let index = 0; index < 10; index += 1) {
const neighbor = `n${index}`;
addNode(neighbor);
addEdge(`edge-${index}`, "center", neighbor, 1);
}
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
aggregationEnabled: false,
collapsedNeighborhoodNodeIds: ["center"],
});
assert.equal(state.selectedRootNodeId, "center");
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
assert.equal(state.selectedVisibleNeighborIds.length, 9);
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
});
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
const left = ["a1", "a2", "a3", "a4"];
const right = ["b1", "b2", "b3", "b4"];
[...left, ...right].forEach((nodeId, index) => {
addNode(nodeId, index < left.length ? "left" : "right");
});
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) {
addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
}
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) {
addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
}
}
}
addEdge("bridge-1", "a1", "b1", 0.1);
addEdge("bridge-2", "a2", "b2", 0.1);
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
assert.equal(state.groupedViewAvailable, true);
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
assert.ok(communityNodes.length >= 2);
const hasCommunityEdge = graph
.edges()
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
assert.equal(hasCommunityEdge, true);
});
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
});
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
addNode("x");
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
});
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
const left = ["a1", "a2", "a3", "a4"];
const right = ["b1", "b2", "b3", "b4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-1", "a1", "b1", 0.1);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
assert.ok(communityNodes.length >= 2, "expected community nodes");
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
assert.ok(resolved !== null, "should resolve a1 to a community node");
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
});
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
addNode("p");
addNode("q");
addEdge("e1", "p", "q");
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
groupedViewAvailable: true,
groupedViewReason: null,
});
assert.equal(state.selectedNodeKind, "none");
assert.equal(state.selectedRootNodeId, null);
});
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
const left = ["c1", "c2", "c3", "c4"];
const right = ["d1", "d2", "d3", "d4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-c1", "c1", "d1", 0.1);
addEdge("bridge-c2", "c2", "d2", 0.1);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
groupedViewAvailable: true,
groupedViewReason: null,
selectedNodeKind: "grouped",
});
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
assert.equal(state.groupedViewAvailable, true);
});
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
const result = checkGroupedViewAvailability();
assert.equal(result.available, false);
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
});
test("checkGroupedViewAvailability returns available when communities exist", () => {
const left = ["e1", "e2", "e3", "e4"];
const right = ["f1", "f2", "f3", "f4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-e1", "e1", "f1", 0.1);
const result = checkGroupedViewAvailability();
assert.equal(result.available, true);
assert.equal(result.reason, null);
});
+158 -163
View File
@@ -1,155 +1,158 @@
# Semantica Plugins (Community Guide)
> **v0.4.0** — 17 domain skills · 3 agents · 8 platform plugins · Knowledge Explorer UI
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.
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.
This README covers installation across every supported platform.
---
## Supported Platforms
## 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`
---
| Platform | Method | Config file |
|---|---|---|
| Claude Code | Native plugin bundle | `plugins/.claude-plugin/plugin.json` |
| Cursor | Native plugin bundle | `plugins/.cursor-plugin/plugin.json` |
| Codex CLI | Native plugin bundle | `plugins/.codex-plugin/plugin.json` |
| Windsurf | MCP server + plugin bundle | `plugins/.windsurf-plugin/plugin.json` |
| Cline (VS Code) | MCP server + plugin bundle | `plugins/.cline-plugin/plugin.json` |
| Continue | MCP server | `plugins/.continue-plugin/plugin.json` |
| VS Code | MCP server | `plugins/.vscode-plugin/plugin.json` |
| Claude Desktop | MCP server | — (see MCP section below) |
| Any MCP client | MCP server | `python -m semantica.mcp_server` |
## Prerequisites
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:
## Knowledge Explorer (v0.4.0)
Launch the interactive graph dashboard:
```bash
semantica-explorer --graph my_graph.json --port 8000
```text
plugins/
skills/ ← 17 domain skills
agents/ ← 3 specialized agents
hooks/ ← hooks.json
.claude-plugin/ ← Claude Code manifest
.cursor-plugin/ ← Cursor manifest
.codex-plugin/ ← Codex CLI manifest
.windsurf-plugin/← Windsurf manifest + MCP config
.cline-plugin/ ← Cline manifest + MCP config
.continue-plugin/← Continue manifest + MCP config
.vscode-plugin/ ← VS Code manifest + MCP config
```
Open **http://localhost:5174** to explore:
## Plugin Contents
- **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
- `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
---
## Install and Use in Claude Code
## Installation by Platform
### Local install (fastest)
### Claude Code
From the repository root:
```bash
claude --plugin-dir ./plugins
```
Or inside a session:
If your Claude setup uses plugin commands in-session, use:
```bash
/plugin marketplace add ./plugins
/plugin install semantica@semantica-local
/plugin install ./plugins
```
Verify:
### Install from a GitHub marketplace
Add a marketplace hosted in git:
```bash
/plugin marketplace add <owner>/semantica
```
Install Semantica from that marketplace:
```bash
/plugin install semantica@<marketplace-name>
```
### Verify in Claude
Run one of these in chat:
```text
/semantica:decision list
/semantica:explain decision <decision_id>
```
---
If the plugin is installed correctly, Claude should recognize the `/semantica:*` skills.
### 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
## Install and Use in Codex
1. Ensure your repo marketplace exists at `.agents/plugins/marketplace.json`.
2. Set `source.path` to `./plugins` in the plugin entry.
2. Point the plugin entry `source.path` to `./plugins` (or your chosen plugin directory).
3. Restart Codex and install from the marketplace UI.
Verify:
Codex manifest used by this bundle:
```
- `.codex-plugin/plugin.json`
### Verify in Codex
After install, run a Semantica skill command in chat, for example:
```text
/semantica:causal chain --subject <decision_id> --depth 3
```
---
## Install and Use in Cursor
### Cline
Cursor reads plugin metadata from:
In Cline MCP settings, add:
- `.cursor-plugin/plugin.json`
- `.cursor-plugin/marketplace.json`
```json
{
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"],
"env": {}
}
}
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
```
---
## First Commands to Try
### Windsurf
After installing on any platform, these are good smoke tests:
Add to `~/.codeium/windsurf/mcp_config.json`:
1. `/semantica:decision record <category> "<scenario>" "<reasoning>" <outcome> <confidence>`
2. `/semantica:decision list`
3. `/semantica:causal chain --subject <decision_id> --depth 3`
4. `/semantica:explain decision <decision_id>`
5. `/semantica:validate graph`
## MCP Server (Windsurf · Cline · Continue · VS Code · Claude Desktop · Any tool)
Semantica includes a full MCP server (`semantica/mcp_server.py`) that exposes 12 tools and 3 resources over stdio — compatible with any MCP-aware tool.
### Start the server
```bash
python -m semantica.mcp_server
```
### Configure in your tool
**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
@@ -162,11 +165,31 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
}
```
---
**Windsurf** — `~/.codeium/windsurf/mcp_config.json`:
### Continue
```json
{
"mcpServers": {
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
}
```
Add to `~/.continue/config.json`:
**Cline** — Cline MCP settings panel → Add server:
```json
{
"semantica": {
"command": "python",
"args": ["-m", "semantica.mcp_server"]
}
}
```
**Continue** — `~/.continue/config.json`:
```json
{
@@ -180,50 +203,7 @@ Add to `~/.continue/config.json`:
}
```
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:
**VS Code** — `settings.json`:
```json
{
@@ -236,25 +216,40 @@ Or for the VS Code MCP extension:
}
```
---
### Available MCP tools
## First Commands to Try
| Tool | Description |
|---|---|
| `extract_entities` | Named entity recognition from text |
| `extract_relations` | Relation and triplet extraction from text |
| `record_decision` | Record a decision with full context and metadata |
| `query_decisions` | Query recorded decisions by natural language or category |
| `find_precedents` | Find past decisions similar to a scenario |
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
| `add_entity` | Add a node/entity to the knowledge graph |
| `add_relationship` | Add a directed edge between two entities |
| `run_reasoning` | Run IF/THEN rules over facts to derive new facts |
| `get_graph_analytics` | PageRank centrality and community detection |
| `export_graph` | Export graph as Turtle, JSON-LD, N-Triples, or JSON |
| `get_graph_summary` | Node count, decision count, graph status |
After installing on any platform:
### Available MCP resources
```
/semantica:decision record <category> "<scenario>" "<reasoning>" <outcome> <confidence>
/semantica:decision list
/semantica:causal chain --subject <decision_id> --depth 3
/semantica:explain decision <decision_id>
/semantica:validate graph
/semantica:visualize topology
```
| URI | Description |
|---|---|
| `semantica://graph/summary` | High-level graph statistics |
| `semantica://decisions/list` | All recorded decisions |
| `semantica://schema/info` | Server info and capability list |
---
### Environment variables
| Variable | Description |
|---|---|
| `SEMANTICA_KG_PATH` | Path to a persisted graph to load on start |
| `SEMANTICA_LOG_LEVEL` | Log level: DEBUG, INFO, WARNING (default: WARNING) |
## Community Notes
- Keep `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.
- 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.
-4
View File
@@ -1,9 +1,5 @@
{
"name": "semantica-local",
"owner": {
"name": "Hawksight AI",
"url": "https://github.com/Hawksight-AI/semantica"
},
"plugins": [
{
"name": "semantica",
+2 -1
View File
@@ -25,5 +25,6 @@
"mcp"
],
"skills": "./skills",
"agents": "./agents"
"agents": "./agents",
"hooks": "./hooks/hooks.json"
}
+3 -11
View File
@@ -1,6 +1,6 @@
# Semantica — Cline Plugin
> **v0.4.0**Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
## MCP Server Setup (recommended)
@@ -16,17 +16,9 @@ In Cline settings, add a new MCP server:
}
```
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.
Cline will discover all 12 Semantica tools automatically on connection.
## Requirements
- Python 3.10+
- Python 3.8+
- `pip install semantica`
+3 -11
View File
@@ -1,6 +1,6 @@
# Semantica — Continue Plugin
> **v0.4.0**Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
## MCP Server Setup
@@ -18,17 +18,9 @@ Add to `~/.continue/config.json`:
}
```
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.
Continue will show all Semantica tools in the `@semantica` context provider dropdown.
## Requirements
- Python 3.10+
- Python 3.8+
- `pip install semantica`
+3 -3
View File
@@ -1,6 +1,6 @@
# 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.
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)
@@ -32,7 +32,7 @@ Paste the following into your OpenClaw `mcporter.json` (usually `~/.openclaw/mcp
openclaw gateway restart
```
OpenClaw will automatically discover all 17 Semantica tools and 3 agents.
OpenClaw will automatically discover all 12 Semantica tools and 3 resources.
## Skills
@@ -57,6 +57,6 @@ See [`integrations/openclaw/README.md`](../../integrations/openclaw/README.md) f
## Requirements
- Python 3.10+
- Python 3.8+
- `pip install semantica`
- OpenClaw — [openclaw.ai](https://openclaw.ai)
+2 -14
View File
@@ -1,6 +1,6 @@
# 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).
Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
## MCP Server Setup
@@ -30,19 +30,7 @@ Or if using the VS Code MCP extension directly:
}
```
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+
- Python 3.8+
- `pip install semantica`
+4 -14
View File
@@ -1,6 +1,6 @@
# Semantica — Windsurf Plugin
> **v0.4.0**Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
## MCP Server Setup (recommended)
@@ -17,23 +17,13 @@ Add to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`):
}
```
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.
Windsurf will then have access to all 12 Semantica tools (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.
All 17 skills under `plugins/skills/` are available as slash commands once the plugin is loaded.
## Requirements
- Python 3.10+
- Python 3.8+
- `pip install semantica`
+1 -1
View File
@@ -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 = ["openai>=1.0.0"]
llm-deepseek = ["deepseek>=0.1.0"]
llm-litellm = ["litellm>=1.0.0"]
llm-instructor = ["instructor>=1.0.0"]
+1 -1
View File
@@ -10,7 +10,7 @@ Main exports:
- Config: Configuration management
"""
__version__ = "0.4.0"
__version__ = "0.3.0"
__author__ = "Semantica Contributors"
__license__ = "MIT"
+1 -28
View File
@@ -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, HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
@@ -19,12 +19,7 @@ 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():
@@ -132,17 +127,6 @@ 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(
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
'<title>Semantica Knowledge Explorer</title></head>'
'<body><div id="root"></div></body></html>'
)
@app.get("/api/health")
async def health():
return {"status": "healthy"}
@@ -155,17 +139,6 @@ 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(
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
'<title>Semantica Knowledge Explorer</title></head>'
'<body><div id="root"></div></body></html>'
)
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
assets_dir = static_dir / "assets"
+3 -3
View File
@@ -8,7 +8,7 @@ modify Semantica core.
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, 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 HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
raise KeyError(body.node_id)
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 HTTPException(status_code=404, detail=f"Annotation '{annotation_id}' not found")
raise KeyError(annotation_id)
return None
+5 -5
View File
@@ -5,7 +5,7 @@ Decision routes using ContextGraph-native fallbacks.
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, 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 HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
raise KeyError(decision_id)
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 HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
raise KeyError(decision_id)
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 HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
raise KeyError(decision_id)
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 HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
raise KeyError(decision_id)
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
violation_types = {"violates", "non_compliant", "breaches"}
+12 -15
View File
@@ -6,7 +6,7 @@ import asyncio
import re
from typing import Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
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.add_node(source, "entity", content=source)
session.graph.add_node(source, "entity", content=source)
if session.get_node(target) is None:
session.add_node(target, "entity", content=target)
session.graph.add_node(target, "entity", content=target)
edge_type = body.inferred_edge_type or predicate
session.add_edge(
session.graph.add_edge(
source,
target,
edge_type=edge_type,
@@ -173,12 +173,11 @@ async def extract_entities(
relations=[_safe_dict(relation) for relation in rel_list],
)
except ImportError:
raise HTTPException(
status_code=503,
detail="semantic_extract module not available. Ensure spacy and transformers are installed.",
raise ValueError(
"semantic_extract module not available. Ensure spacy and transformers are installed."
)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}")
raise ValueError(f"Extraction failed: {exc}")
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
@@ -188,11 +187,11 @@ async def predict_links(
):
predictor = session.link_predictor
if predictor is None:
raise HTTPException(status_code=503, detail="LinkPredictor not available; KG extras may not be installed.")
raise ValueError("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 HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
raise KeyError(body.node_id)
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)
@@ -249,9 +248,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 HTTPException(status_code=503, detail="Deduplication module not available.")
raise ValueError("Deduplication module not available.")
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Dedup scan failed: {exc}")
raise ValueError(f"Dedup scan failed: {exc}")
@router.post("/api/reason", response_model=ReasoningResponse)
@@ -296,7 +295,7 @@ async def merge_nodes(
node = await asyncio.to_thread(session.get_node, primary_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Primary node '{primary_id}' not found")
raise ValueError(f"Primary node {primary_id} not found")
def _do_merge() -> tuple[list[str], int]:
removed: list[str] = []
@@ -354,6 +353,4 @@ 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)
+4 -18
View File
@@ -6,9 +6,8 @@ import asyncio
from enum import Enum
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from ...utils.helpers import classify_path_distance
from ..dependencies import get_session
from ..schemas import (
EdgeListResponse,
@@ -84,7 +83,7 @@ async def get_node(
):
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
raise KeyError(node_id)
return _node_response(node)
@@ -142,19 +141,16 @@ 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 HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
raise ValueError("PathFinder not available; KG extras may not be installed.")
graph_dict = await asyncio.to_thread(session.build_graph_dict)
path_fn = (
@@ -162,19 +158,12 @@ async def find_path(
if algorithm == _PathAlgorithm.dijkstra
else path_finder.bfs_shortest_path
)
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}")
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
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,
@@ -182,9 +171,6 @@ 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),
)
+27 -34
View File
@@ -1,4 +1,4 @@
"""
"""
Provenance routes for lineage visualization and exportable reports.
"""
@@ -9,13 +9,33 @@ 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"}
@@ -47,7 +67,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=True)
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
provenance_nodes: List[Dict[str, Any]] = []
for graph_node_id in subgraph.nodes():
node = session.graph.nodes.get(graph_node_id)
@@ -65,19 +85,12 @@ 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,
}
)
@@ -91,7 +104,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("metadata", node.get("properties", {})) if node else {},
"properties": node.get("properties", {}) if node else {},
"lineage": provenance,
}
@@ -116,29 +129,9 @@ 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']}")
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']}`")
lines.extend(["", "## Lineage Edges"])
for edge in report.get("lineage", {}).get("edges", []):
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
return "\n".join(lines)
+1 -1
View File
@@ -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_temporal_patterns, graph_dict)
patterns = await asyncio.to_thread(detector.detect_patterns, graph_dict)
if isinstance(patterns, dict):
patterns = patterns.get("patterns", [])
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
-23
View File
@@ -67,9 +67,6 @@ 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):
@@ -288,23 +285,3 @@ 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]
-398
View File
@@ -1,398 +0,0 @@
"""
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,
)
+58 -100
View File
@@ -4,15 +4,12 @@ Semantica Explorer session helpers.
import base64
import json
import logging
import threading
import time
import uuid
from datetime import UTC, datetime
from datetime import datetime, UTC
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:
@@ -31,8 +28,6 @@ try:
except ImportError:
pass
logger = logging.getLogger(__name__)
class GraphSession:
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
@@ -40,7 +35,6 @@ 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]] = {}
@@ -52,7 +46,6 @@ 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":
@@ -397,28 +390,6 @@ 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,
@@ -426,34 +397,64 @@ class GraphSession:
filters: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
filters = filters or {}
started_at = time.perf_counter()
matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
try:
with self._lock:
raw = self.graph.query(query)[:limit]
except Exception:
raw = []
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:
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)):
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,
}
)
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]
normalized.append({"node": node, "score": result.get("score", 0.0)})
return normalized[:limit]
def get_stats(self) -> Dict[str, Any]:
with self._lock:
@@ -593,51 +594,8 @@ class GraphSession:
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
with self._lock:
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
return self.graph.add_nodes(nodes)
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
with self._lock:
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
return self.graph.add_edges(edges)
+40 -63
View File
@@ -328,18 +328,6 @@ class OWLExporter:
lines.append("</rdf:RDF>")
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.
@@ -354,7 +342,6 @@ 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
@@ -370,73 +357,63 @@ class OWLExporter:
lines.append("")
# Ontology declaration
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(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")}" ;')
lines.append("")
# Classes
for cls in ontology.get("classes", []):
classes = ontology.get("classes", [])
for cls in classes:
class_uri = cls.get("uri") or cls.get("id", "")
class_name = cls.get("name") or cls.get("label", "")
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(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(" .")
lines.append("")
# Object properties
for prop in ontology.get("object_properties", []):
object_properties = ontology.get("object_properties", [])
for prop in object_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:
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
lines.append(f' rdfs:label "{prop_name}" .')
if prop.get("domain"):
domain = prop.get("domain")
if isinstance(domain, list):
for d in domain:
predicates.append(f"rdfs:domain <{d}>")
lines.append(f" rdfs:domain <{d}> ;")
else:
predicates.append(f"rdfs:domain <{domain}>")
range_val = prop.get("range")
if range_val:
lines.append(f" rdfs:domain <{domain}> ;")
if prop.get("range"):
range_val = prop.get("range")
if isinstance(range_val, list):
for r in range_val:
predicates.append(f"rdfs:range <{r}>")
lines.append(f" rdfs:range <{r}> ;")
else:
predicates.append(f"rdfs:range <{range_val}>")
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
lines.append("")
lines.append(f" rdfs:range <{range_val}> ;")
# 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))
if lines[-1].endswith(" ;"):
lines[-1] = lines[-1].rstrip(" ;") + " ."
lines.append("")
return "\n".join(lines)
-2
View File
@@ -126,14 +126,12 @@ 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",
-46
View File
@@ -1,46 +0,0 @@
"""
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)
+19 -38
View File
@@ -104,8 +104,7 @@ class PathFinder:
source: str,
target: str,
weight_attribute: str = "weight",
default_weight: float = 1.0,
directed: bool = True
default_weight: float = 1.0
) -> List[str]:
"""
Find shortest path using Dijkstra's algorithm.
@@ -126,34 +125,32 @@ 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(traversal_graph, current_node):
for neighbor, edge_data in self._get_neighbors(graph, current_node):
if neighbor in visited:
continue
@@ -353,48 +350,44 @@ class PathFinder:
self,
graph: Any,
source: str,
target: str,
directed: bool = True
target: str
) -> 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(traversal_graph, current):
for neighbor, _ in self._get_neighbors(graph, current):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
@@ -571,18 +564,6 @@ 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 = []
+3 -6
View File
@@ -397,7 +397,6 @@ 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)
@@ -940,22 +939,20 @@ 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:
from openai import OpenAI
import deepseek # type: ignore[import-untyped]
if self.api_key:
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
self.client = deepseek.Client(api_key=self.api_key)
except (ImportError, OSError):
self.client = None
self.logger.warning(
"openai library not installed. Install with: pip install semantica[llm-openai]"
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
)
def is_available(self) -> bool:
+3 -15
View File
@@ -188,22 +188,10 @@ async def serve_spa(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API route not found")
# Root path — serve index.html if built, otherwise a welcome JSON response
if full_path in ("", "/"):
index_file = STATIC_DIR / "index.html"
if index_file.is_file():
return FileResponse(index_file)
return JSONResponse({
"name": "Semantica Knowledge Explorer",
"version": __version__,
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
"docs": "/docs",
"health": "/health",
})
normalized_path = os.path.normpath(full_path)
if (
os.path.isabs(normalized_path)
normalized_path in ("", ".")
or os.path.isabs(normalized_path)
or normalized_path == ".."
or normalized_path.startswith(".." + os.sep)
):
@@ -212,7 +200,7 @@ async def serve_spa(full_path: str):
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
safe_rel_path = normalized_path.lstrip("/\\")
rel_parts = Path(safe_rel_path).parts
if any(part in (".", "..") for part in rel_parts):
if any(part in ("", ".", "..") for part in rel_parts):
raise HTTPException(status_code=400, detail="Invalid path")
static_dir_resolved = STATIC_DIR.resolve()
-22
View File
@@ -562,25 +562,3 @@ 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" 01 hops (single edge or self)
"near" 23 hops (closely related)
"mid-range" 46 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"
+22 -118
View File
@@ -53,15 +53,6 @@ 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 (
@@ -127,45 +118,18 @@ 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``)
Accepts either:
- A dict with "entities" and "relationships" keys (canonical format)
- Any object that exposes .entities and .relationships attributes
(duck-typed, e.g. custom dataclasses)
(e.g. a KnowledgeGraph dataclass returned by GraphBuilder.build())
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
@@ -192,7 +156,6 @@ 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]:
"""
@@ -214,9 +177,6 @@ class KGVisualizer:
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:
@@ -266,14 +226,13 @@ 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
)
@@ -570,21 +529,6 @@ 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]],
@@ -594,7 +538,6 @@ 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."""
@@ -697,73 +640,47 @@ class KGVisualizer:
node_text.append(text)
# 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 traces
edge_x = []
edge_y = []
# 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
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])
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
standoff=15 # Distance from target node
)
)
@@ -776,22 +693,9 @@ 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,
+1 -254
View File
@@ -4,7 +4,6 @@ import json
from pathlib import Path
import uuid
import networkx as nx
import pytest
from semantica.context.context_graph import ContextGraph
@@ -36,16 +35,6 @@ 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",
@@ -254,74 +243,6 @@ 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
@@ -451,7 +372,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, 503)
assert response.status_code in (200, 422)
def test_link_prediction(self, client):
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
@@ -717,177 +638,3 @@ 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"
-74
View File
@@ -1,74 +0,0 @@
"""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
-549
View File
@@ -1,549 +0,0 @@
"""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("<http://")]
for block in subject_blocks:
assert block.endswith("."), f"Block does not end with '.': {block!r}"
# Must not have a bare '.' on an interior line
interior_lines = block.splitlines()[:-1]
for ln in interior_lines:
assert not ln.rstrip().endswith(" ."), (
f"Premature closing period inside block: {ln!r}"
)
def test_class_with_subclassof_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/Employee",
"name": "Employee",
"subClassOf": "http://example.org/Person",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
# Must contain both predicates in the same block
assert 'rdfs:label "Employee"' in turtle
assert "rdfs:subClassOf <http://example.org/Person>" 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 <http://example.org/Employee>" in turtle
assert "rdfs:range <http://example.org/Company>" 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 <http://example.org/Y>" 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 "<http://example.org/hasAge>" in turtle
assert "<http://example.org/hasName>" 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 <http://example.org/Person>" 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 "<http://example.org/birthDate>" 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 <http://example.org/A>" in turtle
assert "rdfs:domain <http://example.org/B>" 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 <http://example.org/X>" in turtle
assert "rdfs:range <http://example.org/Y>" 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 <http://example.org/Supervisor>" 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
-84
View File
@@ -821,87 +821,3 @@ 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
@@ -1,348 +0,0 @@
"""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()
@@ -283,166 +283,5 @@ class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
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()