mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa74c6a8c | ||
|
|
ca5f081793 | ||
|
|
6ad1502224 | ||
|
|
b010ba68fa | ||
|
|
7c8dfbd3c0 | ||
|
|
45400c88d3 | ||
|
|
26f6cdf9e5 | ||
|
|
96f88594c2 | ||
|
|
c0d08c46f7 | ||
|
|
5a388d0bcc | ||
|
|
f516aef8fd | ||
|
|
c9a382e676 | ||
|
|
897d950bdc | ||
|
|
dd8fa17db8 | ||
|
|
92801c220e | ||
|
|
738480606c | ||
|
|
f9e0bcf210 | ||
|
|
bb0e9f49e3 | ||
|
|
f95c1612d5 | ||
|
|
8c202a691e | ||
|
|
304b82fbd6 | ||
|
|
8d2dfaa53c | ||
|
|
39aaae778f | ||
|
|
16d628997a | ||
|
|
5e6ad6e87e | ||
|
|
f6198039fa | ||
|
|
983f5301e8 | ||
|
|
5a852169be | ||
|
|
fe6ca7fccb | ||
|
|
66c8431eee | ||
|
|
3e2a0a3f3b | ||
|
|
d22a54353a | ||
|
|
f165679c11 | ||
|
|
17460edca9 | ||
|
|
7e815920ac | ||
|
|
7f93eb7104 | ||
|
|
eec3e8804a | ||
|
|
9cb6073568 | ||
|
|
073c48882c | ||
|
|
be86d1b5db | ||
|
|
6f93f429c4 | ||
|
|
bc683e7a34 | ||
|
|
cda5310949 | ||
|
|
17f88ca600 | ||
|
|
658de23357 | ||
|
|
66e8964d22 | ||
|
|
892ff4b4a7 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 | ||
|
|
2ce5067aa3 | ||
|
|
d056e47ab7 | ||
|
|
8eafd2d024 | ||
|
|
7ba93f6772 | ||
|
|
d466203761 | ||
|
|
730dea7911 | ||
|
|
47764c3033 | ||
|
|
055d2fd98d | ||
|
|
ce66681715 | ||
|
|
655b553262 | ||
|
|
60bf8ec75e |
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -0,0 +1 @@
|
||||
# Intialization
|
||||
@@ -0,0 +1 @@
|
||||
# Initialization
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
|
||||
@@ -111,5 +111,12 @@ 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/
|
||||
|
||||
@@ -7,6 +7,57 @@ 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 24–471 ms; a session with 500 k nodes was effectively unusable.
|
||||
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
|
||||
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
|
||||
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
|
||||
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
|
||||
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
|
||||
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
|
||||
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
|
||||
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
|
||||
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
|
||||
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
|
||||
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
|
||||
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
|
||||
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
|
||||
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
|
||||
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
|
||||
|
||||
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||
|
||||
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||
|
||||
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
|
||||
|
||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:25-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/semantica-explorer
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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 LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
||||
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
||||
|
||||
```bash
|
||||
pip install semantica
|
||||
@@ -170,6 +170,75 @@ 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`.
|
||||
@@ -875,53 +944,6 @@ 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
|
||||
@@ -952,67 +974,6 @@ Run `python -m semantica.server` (or `python -m semantica`) — FastAPI on port
|
||||
- **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
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# Semantica v0.3.0 — Release Notes
|
||||
|
||||
**Released:** 2026-03-10
|
||||
**PyPI:** `pip install semantica`
|
||||
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
||||
**Classification:** Production/Stable
|
||||
|
||||
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
| Contributor | Role |
|
||||
|------------|------|
|
||||
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
||||
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
||||
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
||||
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
||||
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 — Stable (2026-03-10)
|
||||
|
||||
### Context Graph Feature Completeness
|
||||
|
||||
**Temporal Validity Windows** (by @KaifAhmad1)
|
||||
|
||||
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
||||
|
||||
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
||||
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
||||
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
||||
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
||||
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
||||
|
||||
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
||||
|
||||
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
||||
|
||||
**Cross-Graph Navigation** (by @KaifAhmad1)
|
||||
|
||||
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
||||
|
||||
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
||||
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
||||
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
||||
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
||||
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
||||
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
||||
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
||||
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
||||
|
||||
**Other Fixes** (by @KaifAhmad1)
|
||||
|
||||
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
||||
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
||||
|
||||
**Test Coverage Added**
|
||||
|
||||
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
||||
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-beta — Beta (2026-03-07)
|
||||
|
||||
### Semantic Extraction Fixes
|
||||
|
||||
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
||||
|
||||
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
||||
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
||||
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
||||
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
||||
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
||||
|
||||
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
||||
|
||||
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
||||
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
||||
|
||||
### Incremental / Delta Processing
|
||||
|
||||
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
||||
|
||||
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
||||
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
||||
- Version snapshot management with graph URI tracking and metadata storage.
|
||||
- `prune_versions()` for automatic snapshot retention cleanup.
|
||||
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
||||
|
||||
### Deduplication v2
|
||||
|
||||
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
||||
|
||||
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
||||
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
||||
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
||||
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
||||
|
||||
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
||||
|
||||
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
||||
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
||||
- **18–25% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
|
||||
|
||||
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Canonicalisation engine with predicate synonym mapping (`works_for` → `employed_by`).
|
||||
- O(1) hash matching for exact canonical signatures.
|
||||
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
|
||||
- **6.98x faster** than legacy mode (83ms vs 579ms).
|
||||
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
|
||||
|
||||
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
||||
|
||||
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
|
||||
- Full backward compatibility maintained — legacy mode remains the default.
|
||||
|
||||
### Export Formats
|
||||
|
||||
**ArangoDB AQL Export** (PR #342, by @tibisabau)
|
||||
|
||||
- Full AQL INSERT statement generation for vertices and edges.
|
||||
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
|
||||
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
|
||||
- 17 tests, 100% pass rate.
|
||||
|
||||
**Apache Parquet Export** (PR #343, by @tibisabau)
|
||||
|
||||
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
|
||||
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
|
||||
- `export_parquet()` convenience function; `.parquet` auto-detection.
|
||||
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
|
||||
- 25 tests, 100% pass rate.
|
||||
|
||||
### Bug Fixes & Test Suite Stabilisation
|
||||
|
||||
**Test Suite Fixes** (by @KaifAhmad1)
|
||||
|
||||
Context module:
|
||||
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
|
||||
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
|
||||
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
|
||||
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
|
||||
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
|
||||
|
||||
KG module:
|
||||
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
|
||||
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
|
||||
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
|
||||
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
|
||||
|
||||
Pipeline module:
|
||||
- Retry loop fixed — now correctly iterates to `max_retries`.
|
||||
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
|
||||
- `add_step()` fixed to return the created `PipelineStep`.
|
||||
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
|
||||
|
||||
Other:
|
||||
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
|
||||
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
|
||||
- Windows cp1252 encoding fix in test files (emoji → ASCII).
|
||||
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
|
||||
|
||||
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0-alpha — Alpha (2026-02-19)
|
||||
|
||||
### Context & Decision Intelligence
|
||||
|
||||
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
|
||||
|
||||
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
|
||||
|
||||
- Full decision lifecycle: `record_decision()` → `add_decision()` → `add_causal_relationship()` → `trace_decision_chain()` → `analyze_decision_impact()` → `analyze_decision_influence()` → `find_similar_decisions()`
|
||||
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
|
||||
- `AgentMemory` with working, conversation, and long-term memory tiers
|
||||
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
|
||||
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
|
||||
- Decision influence analysis via centrality measures and causal chain tracking
|
||||
- GraphStore validation preventing runtime failures; secure logging
|
||||
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
|
||||
|
||||
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
|
||||
|
||||
- Fixed empty/None decision ID handling in `add_decision()`
|
||||
- Fixed None metadata handling preventing `TypeError`
|
||||
- Fixed causal chain depth logic and node exclusion
|
||||
- Fixed nonexistent node handling in `add_causal_relationship()`
|
||||
- Fixed precedent search direction in `find_precedents()`
|
||||
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
|
||||
- Fixed UUID generation across all decision models
|
||||
- All 71 context tests passing
|
||||
|
||||
### Knowledge Graph Algorithms
|
||||
|
||||
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
|
||||
|
||||
- 30+ graph algorithms across 7 categories
|
||||
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
|
||||
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
|
||||
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
|
||||
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
|
||||
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
|
||||
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
|
||||
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
|
||||
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
|
||||
|
||||
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
|
||||
|
||||
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
|
||||
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
|
||||
- `ContextRetriever` with multi-hop reasoning
|
||||
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
|
||||
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
|
||||
|
||||
### Graph Database Backends
|
||||
|
||||
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
|
||||
|
||||
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
|
||||
- SQL injection vulnerabilities fixed with input validation
|
||||
- psycopg2-binary dependency and migration guide added
|
||||
- Fixed parameter replacement and test mock leakage
|
||||
|
||||
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
|
||||
|
||||
- Native PostgreSQL vector storage using the pgvector extension
|
||||
- Multiple distance metrics: cosine, L2/Euclidean, inner product
|
||||
- HNSW and IVFFlat indexing for approximate nearest neighbour search
|
||||
- JSONB metadata storage with flexible filtering; batch operations
|
||||
- Connection pooling with psycopg3/psycopg2 fallback
|
||||
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
|
||||
- 36+ tests with Docker integration
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
|
||||
|
||||
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
|
||||
- Added `ValidationError` when no resources can be allocated
|
||||
- Progress tracking updates moved outside lock scope
|
||||
- 6 regression tests for deadlock prevention
|
||||
|
||||
**Security Configuration** (by @KaifAhmad1)
|
||||
|
||||
- Dependabot bi-weekly security updates with manual review
|
||||
- Automated security scans (Bandit, Safety, Semgrep) on schedule
|
||||
- Security-critical package grouping; zero auto-merge policy
|
||||
|
||||
---
|
||||
|
||||
## Summary by the Numbers
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests passing | **886+** |
|
||||
| Test failures | **0** |
|
||||
| Context tests | 335 |
|
||||
| KG tests | ~430 |
|
||||
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
|
||||
| Reasoning tests | 19 |
|
||||
| Real-world scenario tests | 85 |
|
||||
| PyPI classifier | Production/Stable |
|
||||
| Python support | 3.8 – 3.12 |
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
pip install --upgrade semantica
|
||||
```
|
||||
|
||||
No breaking changes. All new parameters have safe defaults and all new methods are additive.
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Deduplication & Conflict Resolution Strategies Summary
|
||||
|
||||
## Quick Reference by Use Case
|
||||
|
||||
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
||||
|----------|---------------------|----------------|-------------------|---------------------|
|
||||
| **Finance** |
|
||||
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
||||
| **Biomedical** |
|
||||
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
||||
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
| **Cybersecurity** |
|
||||
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
||||
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
||||
| **Blockchain** |
|
||||
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
||||
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
||||
| **Intelligence** |
|
||||
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
||||
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
||||
| **Renewable Energy** |
|
||||
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||
| **Supply Chain** |
|
||||
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||
|
||||
---
|
||||
|
||||
## Strategy Rationale by Domain
|
||||
|
||||
### Finance
|
||||
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
||||
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
||||
|
||||
### Biomedical
|
||||
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
||||
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
||||
|
||||
### Cybersecurity
|
||||
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
||||
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
||||
|
||||
### Blockchain
|
||||
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
||||
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
||||
|
||||
### Intelligence
|
||||
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
||||
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
||||
|
||||
### Renewable Energy
|
||||
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
||||
|
||||
### Supply Chain
|
||||
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
||||
|
||||
---
|
||||
|
||||
## Method Distribution
|
||||
|
||||
### Deduplication Methods (9 total)
|
||||
- `pairwise`: 2 notebooks (real-time processing)
|
||||
- `batch`: 3 notebooks (large datasets)
|
||||
- `incremental`: 2 notebooks (streaming/continuous)
|
||||
- `group`: 2 notebooks (related entities)
|
||||
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
||||
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
||||
- `exact` (EntityResolver): 1 notebook (exact matching)
|
||||
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
||||
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
||||
|
||||
### Merge Strategies (5 total)
|
||||
- `keep_first`: 1 notebook (first detection priority)
|
||||
- `keep_last`: 1 notebook (latest information)
|
||||
- `keep_most_complete`: 5 notebooks (preserve all details)
|
||||
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
||||
- `merge_all`: 3 notebooks (combine all information)
|
||||
|
||||
### Conflict Detection Methods (6 total)
|
||||
- `value`: 4 notebooks (property value conflicts)
|
||||
- `type`: 2 notebooks (type/classification conflicts)
|
||||
- `entity`: 2 notebooks (entity-wide conflicts)
|
||||
- `relationship`: 3 notebooks (relationship conflicts)
|
||||
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
||||
- `logical`: 2 notebooks (logical inconsistencies)
|
||||
|
||||
### Conflict Resolution Strategies (6 total)
|
||||
- `voting`: 5 notebooks (majority vote)
|
||||
- `credibility_weighted`: 4 notebooks (source credibility)
|
||||
- `most_recent`: 3 notebooks (latest data)
|
||||
- `first_seen`: 1 notebook (first detection)
|
||||
- `highest_confidence`: 2 notebooks (most confident)
|
||||
- `expert_review`: 1 notebook (manual review)
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
||||
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
||||
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
||||
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
||||
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
||||
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
||||
|
||||
Generated
+857
-315
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"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"
|
||||
|
||||
+28
-3
@@ -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 = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
@@ -311,14 +311,39 @@ 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>('explore');
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||
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
|
||||
@@ -451,7 +476,7 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<aside className="app-rail">
|
||||
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
|
||||
<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>
|
||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface NodeAttributes {
|
||||
haloColor?: string;
|
||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||
highlighted?: boolean;
|
||||
communityId?: string;
|
||||
isCommunityGroup?: boolean;
|
||||
memberCount?: number;
|
||||
anchorNodeId?: string | null;
|
||||
|
||||
nodeType: string;
|
||||
content: string;
|
||||
@@ -74,6 +78,12 @@ 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,6 +2,7 @@ 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;
|
||||
@@ -14,10 +15,16 @@ 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;
|
||||
@@ -146,6 +153,10 @@ function PathFlowViz({
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
inspectableNodeId,
|
||||
selectedNodeKind = "none",
|
||||
canActivateFocused = false,
|
||||
focusedUnavailableReason = null,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
@@ -171,7 +182,38 @@ export function GraphInspectorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||
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 {
|
||||
color?: string;
|
||||
content?: string;
|
||||
label?: string;
|
||||
@@ -194,12 +236,26 @@ 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 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
<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={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
@@ -224,7 +280,7 @@ export function GraphInspectorPanel({
|
||||
<button
|
||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||
onClick={onRunPredictions}
|
||||
disabled={isRunningPredictions}
|
||||
disabled={isRunningPredictions || !actionNodeId}
|
||||
>
|
||||
{isRunningPredictions ? (
|
||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||
@@ -232,10 +288,10 @@ export function GraphInspectorPanel({
|
||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||
Provenance MD
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,7 +313,7 @@ export function GraphInspectorPanel({
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
@@ -376,6 +432,14 @@ 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,6 +3,7 @@ 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,
|
||||
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
const EMPTY_PATH: string[] = [];
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
@@ -67,6 +69,10 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +119,10 @@ 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]);
|
||||
|
||||
@@ -447,9 +457,16 @@ 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,5 +1,6 @@
|
||||
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";
|
||||
@@ -11,6 +12,7 @@ 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,
|
||||
@@ -23,6 +25,7 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectToggle,
|
||||
GraphEffectsState,
|
||||
@@ -30,6 +33,7 @@ import type {
|
||||
GraphLoadProgress,
|
||||
GraphLoadSummary,
|
||||
GraphSelectedEdgeState,
|
||||
GraphSelectedNodeKind,
|
||||
GraphSelectedNodeState,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
@@ -89,7 +93,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathFlowEnabled: false,
|
||||
lensEnabled: false,
|
||||
temporalEmphasisEnabled: false,
|
||||
semanticRegionsEnabled: true,
|
||||
semanticRegionsEnabled: false,
|
||||
contoursEnabled: false,
|
||||
pathfindingEnabled: false,
|
||||
communitiesEnabled: false,
|
||||
@@ -105,6 +109,15 @@ 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);
|
||||
@@ -494,7 +507,10 @@ function buildRealtimeEdgeAttributes(payload: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
function buildSelectedNodeState(
|
||||
nodeId: string,
|
||||
displayState: GraphDisplayStateSnapshot,
|
||||
): GraphSelectedNodeState | null {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -519,31 +535,64 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
if (!edgeId || !graph.hasEdge(edgeId)) {
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [sourceId, targetId] = graph.extremities(edgeId);
|
||||
const attributes = graph.getEdgeAttributes(edgeId) as {
|
||||
const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
|
||||
const attributes = displayGraph.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 sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
|
||||
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
|
||||
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 properties = attributes.properties ?? {};
|
||||
const familyId = String(attributes.familyId || edgeId);
|
||||
let familySize = 0;
|
||||
let siblingCount = 0;
|
||||
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
|
||||
const edgeAttrs = candidateAttrs as { familyId?: string };
|
||||
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
|
||||
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
|
||||
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) {
|
||||
familySize += 1;
|
||||
}
|
||||
if (candidateSource === sourceId && candidateTarget === targetId) {
|
||||
@@ -551,6 +600,11 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
}
|
||||
});
|
||||
|
||||
if (attributes.isAggregated) {
|
||||
siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
|
||||
familySize = Math.max(familySize, siblingCount);
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeId,
|
||||
familyId,
|
||||
@@ -564,6 +618,12 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,9 +684,15 @@ 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 [viewMode, setViewMode] = useState<GraphViewMode>("focused");
|
||||
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 [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
@@ -663,15 +729,24 @@ export function GraphWorkspace() {
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "focused",
|
||||
viewMode: "full",
|
||||
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);
|
||||
@@ -700,7 +775,7 @@ export function GraphWorkspace() {
|
||||
settlingOverlayTimeoutRef.current = null;
|
||||
}, 900);
|
||||
},
|
||||
onProgress: setLoadingProgress,
|
||||
onProgress: handleLoadProgress,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -762,6 +837,7 @@ export function GraphWorkspace() {
|
||||
});
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
setActiveNodeCount(data.active_node_count);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
} catch (fetchError) {
|
||||
@@ -777,16 +853,166 @@ 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) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
if (!nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
const nextSelectedNodeId = nodeId;
|
||||
|
||||
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
|
||||
setLastGroupedSelectedNodeId(nodeId);
|
||||
}
|
||||
|
||||
setSelectedNodeId(nextSelectedNodeId);
|
||||
setSelectedEdgeId("");
|
||||
setPathResult(null);
|
||||
setSearchResults([]);
|
||||
setSearchError("");
|
||||
if (nodeId) {
|
||||
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, []);
|
||||
}, [viewMode]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -815,14 +1041,14 @@ export function GraphWorkspace() {
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleRunPredictions = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
setIsRunningPredictions(true);
|
||||
try {
|
||||
const response = await fetch("/api/enrich/links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: selectedNodeId,
|
||||
node_id: inspectableNodeId,
|
||||
top_n: 6,
|
||||
candidate_type: predictionType || undefined,
|
||||
min_score: 0,
|
||||
@@ -839,13 +1065,13 @@ export function GraphWorkspace() {
|
||||
} finally {
|
||||
setIsRunningPredictions(false);
|
||||
}
|
||||
}, [predictionType, selectedNodeId]);
|
||||
}, [inspectableNodeId, predictionType]);
|
||||
|
||||
const handleTracePath = useCallback(async () => {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
if (!inspectableNodeId || !pathTargetId.trim()) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
@@ -862,12 +1088,12 @@ export function GraphWorkspace() {
|
||||
console.error("[GraphWorkspace] path trace failed", pathError);
|
||||
setPathResult(null);
|
||||
}
|
||||
}, [pathTargetId, selectedNodeId]);
|
||||
}, [inspectableNodeId, pathTargetId]);
|
||||
|
||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
const suffix = format === "markdown" ? "markdown" : "json";
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||
}
|
||||
@@ -875,12 +1101,12 @@ export function GraphWorkspace() {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
}, [selectedNodeId]);
|
||||
}, [inspectableNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -905,6 +1131,7 @@ 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") {
|
||||
@@ -918,6 +1145,7 @@ 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) {
|
||||
@@ -930,32 +1158,163 @@ 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 = Math.min(localNeighborCount, 16);
|
||||
const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [selectedNodeId, viewMode]);
|
||||
if (viewMode === "grouped") {
|
||||
return "Grouped structure view with direct community drill-in";
|
||||
}
|
||||
|
||||
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
|
||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||
const activePath = pathResult?.path ?? [];
|
||||
const activePathEdgeIds = pathResult?.edge_ids ?? [];
|
||||
if (displayState.selectedCollapsedNeighborIds.length > 0) {
|
||||
return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [displayState, selectedNodeId, viewMode]);
|
||||
const graphSummary = summary as GraphLoadSummary | null;
|
||||
const selectedNodeState = useMemo(
|
||||
() => buildSelectedNodeState(selectedNodeId),
|
||||
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedNodeState(selectedNodeId, displayState),
|
||||
[displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const selectedEdgeState = useMemo(
|
||||
() => buildSelectedEdgeState(selectedEdgeId),
|
||||
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
|
||||
[displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const temporalState = useMemo(
|
||||
() => ({
|
||||
@@ -1061,7 +1420,21 @@ export function GraphWorkspace() {
|
||||
focusNode(action.nodeId);
|
||||
return;
|
||||
case "setViewMode":
|
||||
setViewMode(action.viewMode);
|
||||
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));
|
||||
return;
|
||||
case "toggleEffect":
|
||||
setEffectToggle(action.effect, (current) => !current);
|
||||
@@ -1099,7 +1472,7 @@ export function GraphWorkspace() {
|
||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||
return;
|
||||
}
|
||||
}, [focusNode, setEffectToggle]);
|
||||
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
|
||||
|
||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||
@@ -1139,9 +1512,11 @@ export function GraphWorkspace() {
|
||||
getEffectsState: () => effectsState,
|
||||
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
|
||||
getAnalyticsSnapshot: () => graphAnalyticsState,
|
||||
getDisplayState: () => displayState,
|
||||
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
|
||||
dispatchAction: handlePluginAction,
|
||||
}), [
|
||||
displayState,
|
||||
graphAnalyticsState,
|
||||
diagnosticsSnapshot,
|
||||
effectsState,
|
||||
@@ -1267,23 +1642,58 @@ export function GraphWorkspace() {
|
||||
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
|
||||
const groups: GraphToolbarGroup[] = [];
|
||||
|
||||
if (selectedNodeId) {
|
||||
if (hasGraphContent) {
|
||||
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: () => setViewMode("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" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1304,25 +1714,13 @@ export function GraphWorkspace() {
|
||||
id: "zoom-in",
|
||||
label: "+ Zoom In",
|
||||
title: "Zoom in (or scroll up on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedZoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
onClick: () => sceneRef.current?.zoomIn(),
|
||||
},
|
||||
{
|
||||
id: "zoom-out",
|
||||
label: "- Zoom Out",
|
||||
title: "Zoom out (or scroll down on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedUnzoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
onClick: () => sceneRef.current?.zoomOut(),
|
||||
},
|
||||
{
|
||||
id: "fit-view",
|
||||
@@ -1362,18 +1760,42 @@ export function GraphWorkspace() {
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
|
||||
}, [
|
||||
canActivateFocusedMode,
|
||||
displayState.groupedViewAvailable,
|
||||
displayState.groupedViewReason,
|
||||
handlePluginAction,
|
||||
hasGraphContent,
|
||||
isLayoutRunning,
|
||||
pluginToolbarItems,
|
||||
reload,
|
||||
requestViewMode,
|
||||
searchQuery,
|
||||
canActivateFocusedMode,
|
||||
focusedSelectionResolution.reason,
|
||||
selectedNodeId,
|
||||
selectedNodeState,
|
||||
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),
|
||||
@@ -1395,7 +1817,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 && loadingProgress ? (
|
||||
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
|
||||
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
|
||||
) : null}
|
||||
{summary ? (
|
||||
@@ -1504,8 +1926,20 @@ export function GraphWorkspace() {
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
|
||||
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</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.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}
|
||||
@@ -1608,6 +2042,10 @@ 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,6 +33,8 @@ type LinkPrediction = {
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
@@ -137,6 +139,10 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +431,10 @@ 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]);
|
||||
@@ -553,6 +563,19 @@ 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
|
||||
@@ -629,8 +652,8 @@ export function GraphWorkspaceShell() {
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||
|
||||
@@ -24,6 +24,8 @@ 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,11 +5,21 @@ export const focusCameraBehavior: GraphBehavior = {
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "focusNode") {
|
||||
return false;
|
||||
if (action.type === "focusNode") {
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
if (action.type === "centerSelection") {
|
||||
context.centerSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.type === "centerGroupedSelection") {
|
||||
context.centerGroupedSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastFocusedNodeId = "";
|
||||
let lastSelectedNodeId = "";
|
||||
let lastViewMode = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastFocusedNodeId = "";
|
||||
lastSelectedNodeId = "";
|
||||
lastViewMode = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextViewMode = interactionState.viewMode;
|
||||
if (nextViewMode !== lastViewMode) {
|
||||
lastViewMode = nextViewMode;
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
return;
|
||||
}
|
||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
return;
|
||||
}
|
||||
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
context.dispatchAction({
|
||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||
nodeId: nextSelectedNodeId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
@@ -17,6 +19,8 @@ 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,7 +1,8 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ 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: {
|
||||
@@ -190,6 +191,35 @@ 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;
|
||||
@@ -305,10 +335,10 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.88,
|
||||
labelThreshold: 0.92,
|
||||
labelBudget: 28,
|
||||
edgePriorityThreshold: 0.55,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
@@ -317,21 +347,21 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 1.02,
|
||||
labelThreshold: 0.82,
|
||||
labelBudget: 60,
|
||||
edgePriorityThreshold: 0.3,
|
||||
arrowPriorityThreshold: 0.65,
|
||||
edgeSizeScale: 1.05,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1.08,
|
||||
labelThreshold: 0.6,
|
||||
labelBudget: 120,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.8,
|
||||
labelBudget: 40,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
@@ -341,7 +371,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
forceVisibleStates: ["hovered", "selected", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
@@ -391,26 +421,26 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
mutedAlpha: 0.16,
|
||||
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.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 },
|
||||
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 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, 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" },
|
||||
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" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
selectedRing: {
|
||||
@@ -473,6 +503,35 @@ 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",
|
||||
@@ -530,7 +589,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
enabledInDev: IS_DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
const displayState = context.getDisplayState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -82,6 +83,11 @@ 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,
|
||||
@@ -97,6 +103,34 @@ 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) => (
|
||||
@@ -159,6 +193,16 @@ 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,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
@@ -31,6 +32,8 @@ 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 }
|
||||
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
@@ -22,6 +24,8 @@ export interface GraphSceneRuntime {
|
||||
scene: unknown;
|
||||
graph: GraphSceneGraph;
|
||||
displayGraph: GraphSceneGraph;
|
||||
graphVersion: number;
|
||||
layoutMode?: GraphDisplayMeta["layoutMode"];
|
||||
requestRender: () => void;
|
||||
getCameraState: () => GraphCameraState | null;
|
||||
}
|
||||
@@ -37,7 +41,13 @@ 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[];
|
||||
@@ -56,6 +66,8 @@ 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";
|
||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -31,6 +32,28 @@ 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"
|
||||
@@ -245,6 +268,10 @@ export interface GraphSelectedNodeState {
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
visibleNeighborCount: number;
|
||||
collapsedNeighborCount: number;
|
||||
isNeighborhoodCollapsed: boolean;
|
||||
canCollapseNeighborhood: boolean;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
@@ -260,6 +287,12 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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);
|
||||
});
|
||||
+173
-168
@@ -1,195 +1,172 @@
|
||||
# Semantica Plugins (Community Guide)
|
||||
|
||||
Semantica ships a shared plugin bundle under `plugins/` with skills, agents, and hooks for knowledge graphs, context graphs, decision intelligence, reasoning, explainability, provenance, ontology, and export workflows.
|
||||
> **v0.4.0** — 17 domain skills · 3 agents · 8 platform plugins · Knowledge Explorer UI
|
||||
|
||||
This README covers installation across every supported platform.
|
||||
Semantica ships a shared plugin bundle under `plugins/` that works across every major AI coding assistant. Connect any supported platform to Semantica's knowledge graph engine for semantic extraction, decision intelligence, reasoning, provenance, ontology, and export workflows.
|
||||
|
||||
## Supported Platforms
|
||||
---
|
||||
|
||||
| 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` |
|
||||
## Platform Plugins
|
||||
|
||||
Semantica provides a dedicated plugin for each platform. Every plugin shares the same `skills/`, `agents/`, and `hooks/` bundle — only the manifest format differs.
|
||||
|
||||
| # | Platform | Plugin Folder | Setup |
|
||||
|---|----------|--------------|-------|
|
||||
| 1 | **Claude Code** | `.claude-plugin/` | `claude --plugin-dir ./plugins` |
|
||||
| 2 | **Cursor** | `.cursor-plugin/` | Cursor Marketplace → refresh |
|
||||
| 3 | **Codex** | `.codex-plugin/` | Marketplace UI → install |
|
||||
| 4 | **Cline** | `.cline-plugin/` | Cline MCP settings |
|
||||
| 5 | **Windsurf** | `.windsurf-plugin/` | `mcp_config.json` |
|
||||
| 6 | **Continue** | `.continue-plugin/` | `~/.continue/config.json` |
|
||||
| 7 | **OpenClaw** | `.openclaw-plugin/` | `mcporter.json` |
|
||||
| 8 | **VS Code** | `.vscode-plugin/` | `settings.json` MCP entry |
|
||||
|
||||
---
|
||||
|
||||
## What's Included
|
||||
|
||||
```
|
||||
plugins/
|
||||
├── skills/ # 17 domain skills (slash commands)
|
||||
├── agents/ # 3 specialized agents
|
||||
├── hooks/ # hooks.json
|
||||
├── .claude-plugin/ # Claude Code manifest + marketplace
|
||||
├── .cursor-plugin/ # Cursor manifest + marketplace
|
||||
├── .codex-plugin/ # Codex manifest + marketplace
|
||||
├── .cline-plugin/ # Cline manifest + marketplace
|
||||
├── .windsurf-plugin/ # Windsurf manifest + marketplace
|
||||
├── .continue-plugin/ # Continue manifest + marketplace
|
||||
├── .openclaw-plugin/ # OpenClaw manifest + marketplace
|
||||
└── .vscode-plugin/ # VS Code manifest + marketplace
|
||||
```
|
||||
|
||||
### Skills (17)
|
||||
|
||||
`extract` · `ingest` · `query` · `ontology` · `validate` · `deduplicate` · `embed` · `reason` · `decision` · `causal` · `temporal` · `provenance` · `policy` · `explain` · `export` · `change` · `visualize`
|
||||
|
||||
### Agents (3)
|
||||
|
||||
`decision-advisor` · `explainability` · `kg-assistant`
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hawksight-AI/semantica.git
|
||||
cd semantica
|
||||
pip install semantica # Python 3.10+
|
||||
```
|
||||
|
||||
2. Ensure the plugin bundle exists at:
|
||||
---
|
||||
|
||||
```text
|
||||
plugins/
|
||||
skills/ ← 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
|
||||
## Knowledge Explorer (v0.4.0)
|
||||
|
||||
Launch the interactive graph dashboard:
|
||||
|
||||
```bash
|
||||
semantica-explorer --graph my_graph.json --port 8000
|
||||
```
|
||||
|
||||
## Plugin Contents
|
||||
Open **http://localhost:5174** to explore:
|
||||
|
||||
- `skills/`: 17 domain skills (`causal`, `decision`, `explain`, `reason`, `temporal`, etc.)
|
||||
- `agents/`: specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
||||
- `hooks/hooks.json`: plugin hook configuration
|
||||
- `.claude-plugin/plugin.json`: Claude manifest
|
||||
- `.cursor-plugin/plugin.json`: Cursor manifest
|
||||
- `.codex-plugin/plugin.json`: Codex manifest
|
||||
- `*/marketplace.json`: local marketplace definitions
|
||||
- **Graph** — interactive canvas with ForceAtlas2 layout, path highlight, community coloring
|
||||
- **Decisions** — causal chains and outcome analysis
|
||||
- **Reasoning** — run deductive / abductive rules
|
||||
- **SPARQL** — Monaco editor for graph queries
|
||||
- **Vocabulary** — ontology concept tree
|
||||
- **Lineage** — provenance lineage diagram
|
||||
- **Import / Export** — JSON, RDF, Parquet, GraphML
|
||||
|
||||
## Install and Use in Claude Code
|
||||
---
|
||||
|
||||
### Local install (fastest)
|
||||
## Installation by Platform
|
||||
|
||||
From the repository root:
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude --plugin-dir ./plugins
|
||||
```
|
||||
|
||||
If your Claude setup uses plugin commands in-session, use:
|
||||
Or inside a session:
|
||||
|
||||
```bash
|
||||
/plugin install ./plugins
|
||||
/plugin marketplace add ./plugins
|
||||
/plugin install semantica@semantica-local
|
||||
```
|
||||
|
||||
### Install from a GitHub marketplace
|
||||
Verify:
|
||||
|
||||
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.
|
||||
---
|
||||
|
||||
## Install and Use in Codex
|
||||
### Cursor
|
||||
|
||||
Cursor reads `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace.json` automatically. Publish the `plugins/` directory and refresh in Cursor Marketplace to pick up updates.
|
||||
|
||||
Verify:
|
||||
|
||||
```
|
||||
/semantica:visualize topology
|
||||
/semantica:reason deductive "IF Person(x) THEN Mortal(x)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Codex
|
||||
|
||||
1. Ensure your repo marketplace exists at `.agents/plugins/marketplace.json`.
|
||||
2. Point the plugin entry `source.path` to `./plugins` (or your chosen plugin directory).
|
||||
2. Set `source.path` to `./plugins` in the plugin entry.
|
||||
3. Restart Codex and install from the marketplace UI.
|
||||
|
||||
Codex manifest used by this bundle:
|
||||
Verify:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
|
||||
### Verify in Codex
|
||||
|
||||
After install, run a Semantica skill command in chat, for example:
|
||||
|
||||
```text
|
||||
```
|
||||
/semantica:causal chain --subject <decision_id> --depth 3
|
||||
```
|
||||
|
||||
## Install and Use in Cursor
|
||||
---
|
||||
|
||||
Cursor reads plugin metadata from:
|
||||
### Cline
|
||||
|
||||
- `.cursor-plugin/plugin.json`
|
||||
- `.cursor-plugin/marketplace.json`
|
||||
|
||||
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
|
||||
|
||||
After installing on any platform, these are good smoke tests:
|
||||
|
||||
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
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf** — `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cline** — Cline MCP settings panel → Add server:
|
||||
In Cline MCP settings, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
"args": ["-m", "semantica.mcp_server"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Continue** — `~/.continue/config.json`:
|
||||
---
|
||||
|
||||
### Windsurf
|
||||
|
||||
Add to `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "python",
|
||||
"args": ["-m", "semantica.mcp_server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Continue
|
||||
|
||||
Add to `~/.continue/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -203,7 +180,50 @@ python -m semantica.mcp_server
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** — `settings.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:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -216,40 +236,25 @@ python -m semantica.mcp_server
|
||||
}
|
||||
```
|
||||
|
||||
### Available MCP tools
|
||||
---
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `extract_entities` | Named entity recognition from text |
|
||||
| `extract_relations` | Relation and triplet extraction from text |
|
||||
| `record_decision` | Record a decision with full context and metadata |
|
||||
| `query_decisions` | Query recorded decisions by natural language or category |
|
||||
| `find_precedents` | Find past decisions similar to a scenario |
|
||||
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
|
||||
| `add_entity` | Add a node/entity to the knowledge graph |
|
||||
| `add_relationship` | Add a directed edge between two entities |
|
||||
| `run_reasoning` | Run IF/THEN rules over facts to derive new facts |
|
||||
| `get_graph_analytics` | PageRank centrality and community detection |
|
||||
| `export_graph` | Export graph as Turtle, JSON-LD, N-Triples, or JSON |
|
||||
| `get_graph_summary` | Node count, decision count, graph status |
|
||||
## First Commands to Try
|
||||
|
||||
### Available MCP resources
|
||||
After installing on any platform:
|
||||
|
||||
| URI | Description |
|
||||
|---|---|
|
||||
| `semantica://graph/summary` | High-level graph statistics |
|
||||
| `semantica://decisions/list` | All recorded decisions |
|
||||
| `semantica://schema/info` | Server info and capability list |
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SEMANTICA_KG_PATH` | Path to a persisted graph to load on start |
|
||||
| `SEMANTICA_LOG_LEVEL` | Log level: DEBUG, INFO, WARNING (default: WARNING) |
|
||||
---
|
||||
|
||||
## Community Notes
|
||||
|
||||
- Keep plugin name/version/keywords updated in each manifest before publishing.
|
||||
- Keep skill frontmatter consistent (`name` + `description`) for reliable discovery.
|
||||
- For open-source sharing, include this folder as-is so skills, agents, and hooks remain bundled.
|
||||
- Keep `name` / `version` / `keywords` updated in each manifest before publishing.
|
||||
- Keep skill frontmatter (`name` + `description`) consistent for reliable discovery.
|
||||
- Include `plugins/` as-is when sharing — skills, agents, and hooks must stay bundled.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"name": "semantica-local",
|
||||
"owner": {
|
||||
"name": "Hawksight AI",
|
||||
"url": "https://github.com/Hawksight-AI/semantica"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "semantica",
|
||||
|
||||
@@ -25,6 +25,5 @@
|
||||
"mcp"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"agents": "./agents",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
"agents": "./agents"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Semantica — Cline Plugin
|
||||
|
||||
Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
|
||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
|
||||
|
||||
## MCP Server Setup (recommended)
|
||||
|
||||
@@ -16,9 +16,17 @@ In Cline settings, add a new MCP server:
|
||||
}
|
||||
```
|
||||
|
||||
Cline will discover all 12 Semantica tools automatically on connection.
|
||||
Cline will discover all 17 Semantica skills and 3 agents automatically on connection.
|
||||
|
||||
## Knowledge Explorer
|
||||
|
||||
```bash
|
||||
semantica-explorer --graph my_graph.json --port 8000
|
||||
```
|
||||
|
||||
Open `http://localhost:5174` for the interactive dashboard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.10+
|
||||
- `pip install semantica`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Semantica — Continue Plugin
|
||||
|
||||
Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
|
||||
> **v0.4.0** — Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
|
||||
|
||||
## MCP Server Setup
|
||||
|
||||
@@ -18,9 +18,17 @@ Add to `~/.continue/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Continue will show all Semantica tools in the `@semantica` context provider dropdown.
|
||||
Continue will show all 17 Semantica skills in the `@semantica` context provider dropdown.
|
||||
|
||||
## Knowledge Explorer
|
||||
|
||||
```bash
|
||||
semantica-explorer --graph my_graph.json --port 8000
|
||||
```
|
||||
|
||||
Open `http://localhost:5174` for the interactive graph dashboard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.10+
|
||||
- `pip install semantica`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Semantica — OpenClaw Plugin
|
||||
|
||||
Adds all 17 Semantica skills, 3 agents, and the full MCP integration to [OpenClaw](https://openclaw.ai) — the open-source personal AI agent platform.
|
||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and the full MCP integration to [OpenClaw](https://openclaw.ai) — the open-source personal AI agent platform.
|
||||
|
||||
## MCP Server Setup (recommended)
|
||||
|
||||
@@ -32,7 +32,7 @@ Paste the following into your OpenClaw `mcporter.json` (usually `~/.openclaw/mcp
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
OpenClaw will automatically discover all 12 Semantica tools and 3 resources.
|
||||
OpenClaw will automatically discover all 17 Semantica tools and 3 agents.
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -57,6 +57,6 @@ See [`integrations/openclaw/README.md`](../../integrations/openclaw/README.md) f
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.10+
|
||||
- `pip install semantica`
|
||||
- OpenClaw — [openclaw.ai](https://openclaw.ai)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Semantica — VS Code Plugin
|
||||
|
||||
Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
|
||||
> **v0.4.0** — Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
|
||||
|
||||
## MCP Server Setup
|
||||
|
||||
@@ -30,7 +30,19 @@ 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.8+
|
||||
- Python 3.10+
|
||||
- `pip install semantica`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Semantica — Windsurf Plugin
|
||||
|
||||
Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
|
||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
|
||||
|
||||
## MCP Server Setup (recommended)
|
||||
|
||||
@@ -17,13 +17,23 @@ Add to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`):
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Windsurf will have access to all 17 Semantica skills (`extract`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, and more) directly in the AI panel.
|
||||
|
||||
## Skills
|
||||
|
||||
All 17 skills under `plugins/skills/` are available as slash commands once the plugin is loaded.
|
||||
All 17 skills under `plugins/skills/` are available as slash commands once the plugin is loaded:
|
||||
|
||||
`extract` · `ingest` · `query` · `ontology` · `validate` · `deduplicate` · `embed` · `reason` · `decision` · `causal` · `temporal` · `provenance` · `policy` · `explain` · `export` · `change` · `visualize`
|
||||
|
||||
## Knowledge Explorer
|
||||
|
||||
```bash
|
||||
semantica-explorer --graph my_graph.json --port 8000
|
||||
```
|
||||
|
||||
Open `http://localhost:5174` for the interactive graph dashboard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Python 3.10+
|
||||
- `pip install semantica`
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
|
||||
llm-gemini = ["google-genai>=0.1.0"]
|
||||
llm-anthropic = ["anthropic>=0.18.0"]
|
||||
llm-ollama = ["ollama>=0.1.0"]
|
||||
llm-deepseek = ["deepseek>=0.1.0"]
|
||||
llm-deepseek = ["openai>=1.0.0"]
|
||||
llm-litellm = ["litellm>=1.0.0"]
|
||||
llm-instructor = ["instructor>=1.0.0"]
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Main exports:
|
||||
- Config: Configuration management
|
||||
"""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
__author__ = "Semantica Contributors"
|
||||
__license__ = "MIT"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .. import __version__
|
||||
@@ -19,7 +19,12 @@ from .ws import ConnectionManager
|
||||
|
||||
|
||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
@@ -127,6 +132,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
|
||||
if index_path.is_file():
|
||||
return FileResponse(index_path)
|
||||
return HTMLResponse(
|
||||
'<!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"}
|
||||
@@ -139,6 +155,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
|
||||
if index_path.is_file():
|
||||
return FileResponse(index_path)
|
||||
return HTMLResponse(
|
||||
'<!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"
|
||||
|
||||
@@ -8,7 +8,7 @@ modify Semantica core.
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import AnnotationCreate, AnnotationResponse
|
||||
@@ -35,7 +35,7 @@ async def create_annotation(
|
||||
"""Create a new annotation on a node."""
|
||||
node = await asyncio.to_thread(session.get_node, body.node_id)
|
||||
if node is None:
|
||||
raise KeyError(body.node_id)
|
||||
raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
|
||||
|
||||
ann_data = body.model_dump()
|
||||
ann_id = await asyncio.to_thread(session.add_annotation, ann_data)
|
||||
@@ -61,5 +61,5 @@ async def delete_annotation(
|
||||
"""Delete an annotation by ID."""
|
||||
deleted = await asyncio.to_thread(session.delete_annotation, annotation_id)
|
||||
if not deleted:
|
||||
raise KeyError(annotation_id)
|
||||
raise HTTPException(status_code=404, detail=f"Annotation '{annotation_id}' not found")
|
||||
return None
|
||||
|
||||
@@ -5,7 +5,7 @@ Decision routes using ContextGraph-native fallbacks.
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||
@@ -59,7 +59,7 @@ async def get_decision(
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
||||
return _node_to_decision(node)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ async def get_causal_chain(
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
||||
|
||||
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, 5)
|
||||
chain = [
|
||||
@@ -94,7 +94,7 @@ async def get_precedents(
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
||||
|
||||
properties = node.get("properties", {})
|
||||
category = str(properties.get("category", ""))
|
||||
@@ -132,7 +132,7 @@ async def check_compliance(
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||
if node is None:
|
||||
raise KeyError(decision_id)
|
||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
||||
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
violation_types = {"violates", "non_compliant", "breaches"}
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
|
||||
continue
|
||||
source, target = args
|
||||
if session.get_node(source) is None:
|
||||
session.graph.add_node(source, "entity", content=source)
|
||||
session.add_node(source, "entity", content=source)
|
||||
if session.get_node(target) is None:
|
||||
session.graph.add_node(target, "entity", content=target)
|
||||
session.add_node(target, "entity", content=target)
|
||||
edge_type = body.inferred_edge_type or predicate
|
||||
session.graph.add_edge(
|
||||
session.add_edge(
|
||||
source,
|
||||
target,
|
||||
edge_type=edge_type,
|
||||
@@ -173,11 +173,12 @@ async def extract_entities(
|
||||
relations=[_safe_dict(relation) for relation in rel_list],
|
||||
)
|
||||
except ImportError:
|
||||
raise ValueError(
|
||||
"semantic_extract module not available. Ensure spacy and transformers are installed."
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="semantic_extract module not available. Ensure spacy and transformers are installed.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Extraction failed: {exc}")
|
||||
raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}")
|
||||
|
||||
|
||||
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
|
||||
@@ -187,11 +188,11 @@ async def predict_links(
|
||||
):
|
||||
predictor = session.link_predictor
|
||||
if predictor is None:
|
||||
raise ValueError("LinkPredictor not available; KG extras may not be installed.")
|
||||
raise HTTPException(status_code=503, detail="LinkPredictor not available; KG extras may not be installed.")
|
||||
|
||||
node = await asyncio.to_thread(session.get_node, body.node_id)
|
||||
if node is None:
|
||||
raise KeyError(body.node_id)
|
||||
raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
|
||||
|
||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||
@@ -248,9 +249,9 @@ async def detect_duplicates(
|
||||
duplicate_list = duplicates if isinstance(duplicates, list) else getattr(duplicates, "duplicates", [])
|
||||
return DedupResponse(duplicates=[_safe_dict(item) for item in duplicate_list], total_flagged=len(duplicate_list))
|
||||
except ImportError:
|
||||
raise ValueError("Deduplication module not available.")
|
||||
raise HTTPException(status_code=503, detail="Deduplication module not available.")
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Dedup scan failed: {exc}")
|
||||
raise HTTPException(status_code=422, detail=f"Dedup scan failed: {exc}")
|
||||
|
||||
|
||||
@router.post("/api/reason", response_model=ReasoningResponse)
|
||||
@@ -295,7 +296,7 @@ async def merge_nodes(
|
||||
|
||||
node = await asyncio.to_thread(session.get_node, primary_id)
|
||||
if node is None:
|
||||
raise ValueError(f"Primary node {primary_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Primary node '{primary_id}' not found")
|
||||
|
||||
def _do_merge() -> tuple[list[str], int]:
|
||||
removed: list[str] = []
|
||||
@@ -353,4 +354,6 @@ async def merge_nodes(
|
||||
return removed, edges_updated
|
||||
|
||||
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
|
||||
if removed_ids:
|
||||
await asyncio.to_thread(session.rebuild_search_index)
|
||||
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
|
||||
|
||||
@@ -6,8 +6,9 @@ import asyncio
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ...utils.helpers import classify_path_distance
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
EdgeListResponse,
|
||||
@@ -83,7 +84,7 @@ async def get_node(
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise KeyError(node_id)
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
return _node_response(node)
|
||||
|
||||
|
||||
@@ -141,16 +142,19 @@ class _PathAlgorithm(str, Enum):
|
||||
dijkstra = "dijkstra"
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
path_finder = session.path_finder
|
||||
if path_finder is None:
|
||||
raise ValueError("PathFinder not available; KG extras may not be installed.")
|
||||
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
|
||||
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_fn = (
|
||||
@@ -158,12 +162,19 @@ async def find_path(
|
||||
if algorithm == _PathAlgorithm.dijkstra
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
|
||||
try:
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if not path_nodes:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
|
||||
|
||||
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
target=target,
|
||||
@@ -171,6 +182,9 @@ async def find_path(
|
||||
path=path_nodes,
|
||||
edge_ids=edge_ids,
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
"""
|
||||
Provenance routes for lineage visualization and exportable reports.
|
||||
"""
|
||||
|
||||
@@ -9,33 +9,13 @@ from typing import Any, Dict, List, Optional
|
||||
import networkx as nx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: str
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
_AGENT_TYPES = {"person", "organization", "system", "agent"}
|
||||
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
|
||||
|
||||
@@ -67,7 +47,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
||||
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
|
||||
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
|
||||
|
||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
|
||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
|
||||
provenance_nodes: List[Dict[str, Any]] = []
|
||||
for graph_node_id in subgraph.nodes():
|
||||
node = session.graph.nodes.get(graph_node_id)
|
||||
@@ -85,12 +65,19 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
||||
|
||||
provenance_edges: List[Dict[str, Any]] = []
|
||||
for source, target, data in subgraph.edges(data=True):
|
||||
if target == node_id:
|
||||
direction = "upstream"
|
||||
elif source == node_id:
|
||||
direction = "downstream"
|
||||
else:
|
||||
direction = "lateral"
|
||||
provenance_edges.append(
|
||||
{
|
||||
"id": f"{source}-{target}",
|
||||
"source": source,
|
||||
"target": target,
|
||||
"label": data.get("label", "related_to"),
|
||||
"direction": direction,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -104,7 +91,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
|
||||
"node_id": node_id,
|
||||
"label": node.get("content", node_id) if node else node_id,
|
||||
"type": node.get("type", "entity") if node else "entity",
|
||||
"properties": node.get("properties", {}) if node else {},
|
||||
"properties": node.get("metadata", node.get("properties", {})) if node else {},
|
||||
"lineage": provenance,
|
||||
}
|
||||
|
||||
@@ -129,9 +116,29 @@ def _render_markdown(report: Dict[str, Any]) -> str:
|
||||
for node in report.get("lineage", {}).get("nodes", []):
|
||||
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
|
||||
|
||||
lines.extend(["", "## Lineage Edges"])
|
||||
for edge in report.get("lineage", {}).get("edges", []):
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
edges = report.get("lineage", {}).get("edges", [])
|
||||
grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
|
||||
for edge in edges:
|
||||
direction = edge.get("direction", "lateral")
|
||||
if direction not in grouped_edges:
|
||||
direction = "lateral"
|
||||
grouped_edges[direction].append(edge)
|
||||
|
||||
if grouped_edges["upstream"]:
|
||||
lines.extend(["", "## Upstream"])
|
||||
for edge in grouped_edges["upstream"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
if grouped_edges["downstream"]:
|
||||
lines.extend(["", "## Downstream"])
|
||||
for edge in grouped_edges["downstream"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
if grouped_edges["lateral"]:
|
||||
lines.extend(["", "## Lateral"])
|
||||
for edge in grouped_edges["lateral"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ async def temporal_patterns(
|
||||
|
||||
detector = TemporalPatternDetector()
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
patterns = await asyncio.to_thread(detector.detect_patterns, graph_dict)
|
||||
patterns = await asyncio.to_thread(detector.detect_temporal_patterns, graph_dict)
|
||||
if isinstance(patterns, dict):
|
||||
patterns = patterns.get("patterns", [])
|
||||
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
|
||||
|
||||
@@ -67,6 +67,9 @@ class PathResponse(BaseModel):
|
||||
path: List[str]
|
||||
edge_ids: List[str] = Field(default_factory=list)
|
||||
total_weight: float = 0.0
|
||||
directed: bool = True
|
||||
hop_count: int = 0
|
||||
distance_band: str = "direct"
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
@@ -285,3 +288,23 @@ class MergeResponse(BaseModel):
|
||||
merged_into: str
|
||||
removed_ids: List[str]
|
||||
edges_updated: int
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
direction: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Explorer-local in-memory node search index.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import heapq
|
||||
import re
|
||||
from collections import OrderedDict, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
_CURATED_ALIAS_KEYS = (
|
||||
"label",
|
||||
"name",
|
||||
"title",
|
||||
"pref_label",
|
||||
"preferred_label",
|
||||
"prefLabel",
|
||||
"aliases",
|
||||
"alias",
|
||||
"synonyms",
|
||||
"synonym",
|
||||
"symbol",
|
||||
"display_name",
|
||||
"displayName",
|
||||
"text",
|
||||
"content",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
return _WHITESPACE_RE.sub(" ", text)
|
||||
|
||||
|
||||
def _tokenize(text: str) -> Tuple[str, ...]:
|
||||
if not text:
|
||||
return ()
|
||||
return tuple(dict.fromkeys(_TOKEN_RE.findall(text)))
|
||||
|
||||
|
||||
def _collect_text_fragments(value: Any, fragments: List[str], *, limit: int = 64) -> None:
|
||||
if value is None or len(fragments) >= limit:
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
for nested in value.values():
|
||||
_collect_text_fragments(nested, fragments, limit=limit)
|
||||
if len(fragments) >= limit:
|
||||
return
|
||||
return
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
for nested in value:
|
||||
_collect_text_fragments(nested, fragments, limit=limit)
|
||||
if len(fragments) >= limit:
|
||||
return
|
||||
return
|
||||
|
||||
normalized = _normalize_text(value)
|
||||
if normalized:
|
||||
fragments.append(normalized)
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexedNodeDocument:
|
||||
node_id: str
|
||||
normalized_id: str
|
||||
node_type: str
|
||||
exact_terms: frozenset[str]
|
||||
tokens: frozenset[str]
|
||||
primary_text: str
|
||||
secondary_text: str
|
||||
confidence: Optional[float]
|
||||
tags: Tuple[str, ...]
|
||||
|
||||
|
||||
class GraphSearchIndex:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cache_size: int = 128,
|
||||
prefix_min_length: int = 2,
|
||||
prefix_max_length: int = 12,
|
||||
secondary_scan_limit: int = 12000,
|
||||
) -> None:
|
||||
self.cache_size = cache_size
|
||||
self.prefix_min_length = prefix_min_length
|
||||
self.prefix_max_length = prefix_max_length
|
||||
self.secondary_scan_limit = secondary_scan_limit
|
||||
self._documents: Dict[str, IndexedNodeDocument] = {}
|
||||
self._exact_index: DefaultDict[str, set[str]] = defaultdict(set)
|
||||
self._token_index: DefaultDict[str, set[str]] = defaultdict(set)
|
||||
self._prefix_index: DefaultDict[str, set[str]] = defaultdict(set)
|
||||
self._ordered_node_ids: List[str] = []
|
||||
self._cache: OrderedDict[Tuple[Any, ...], List[Tuple[str, float]]] = OrderedDict()
|
||||
|
||||
def rebuild(self, nodes: Iterable[Dict[str, Any]]) -> None:
|
||||
self._documents.clear()
|
||||
self._exact_index.clear()
|
||||
self._token_index.clear()
|
||||
self._prefix_index.clear()
|
||||
self._ordered_node_ids = []
|
||||
self.clear_cache()
|
||||
|
||||
for node in nodes:
|
||||
self.upsert(node, clear_cache=False)
|
||||
|
||||
self._ordered_node_ids.sort()
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
self._cache.clear()
|
||||
|
||||
def remove(self, node_id: str, *, clear_cache: bool = True) -> None:
|
||||
existing = self._documents.pop(node_id, None)
|
||||
if existing is None:
|
||||
return
|
||||
|
||||
for term in existing.exact_terms:
|
||||
bucket = self._exact_index.get(term)
|
||||
if bucket is None:
|
||||
continue
|
||||
bucket.discard(node_id)
|
||||
if not bucket:
|
||||
self._exact_index.pop(term, None)
|
||||
|
||||
for token in existing.tokens:
|
||||
bucket = self._token_index.get(token)
|
||||
if bucket is None:
|
||||
continue
|
||||
bucket.discard(node_id)
|
||||
if not bucket:
|
||||
self._token_index.pop(token, None)
|
||||
|
||||
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
|
||||
prefix = token[:length]
|
||||
prefix_bucket = self._prefix_index.get(prefix)
|
||||
if prefix_bucket is None:
|
||||
continue
|
||||
prefix_bucket.discard(node_id)
|
||||
if not prefix_bucket:
|
||||
self._prefix_index.pop(prefix, None)
|
||||
|
||||
pos = bisect.bisect_left(self._ordered_node_ids, node_id)
|
||||
if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
|
||||
self._ordered_node_ids.pop(pos)
|
||||
|
||||
if clear_cache:
|
||||
self.clear_cache()
|
||||
|
||||
def upsert(self, node: Dict[str, Any], *, clear_cache: bool = True) -> None:
|
||||
node_id = str(node.get("id", "")).strip()
|
||||
if not node_id:
|
||||
return
|
||||
|
||||
self.remove(node_id, clear_cache=False)
|
||||
document = self._build_document(node)
|
||||
self._documents[node_id] = document
|
||||
|
||||
for term in document.exact_terms:
|
||||
self._exact_index[term].add(node_id)
|
||||
|
||||
for token in document.tokens:
|
||||
self._token_index[token].add(node_id)
|
||||
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
|
||||
self._prefix_index[token[:length]].add(node_id)
|
||||
|
||||
bisect.insort(self._ordered_node_ids, node_id)
|
||||
|
||||
if clear_cache:
|
||||
self.clear_cache()
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[List[Tuple[str, float]], Dict[str, Any]]:
|
||||
normalized_query = _normalize_text(query)
|
||||
filters = filters or {}
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"cache_hit": False,
|
||||
"path": "empty",
|
||||
"candidates": 0,
|
||||
}
|
||||
if not normalized_query:
|
||||
return [], diagnostics
|
||||
|
||||
cache_key = self._cache_key(normalized_query, limit, filters)
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
self._cache.move_to_end(cache_key)
|
||||
diagnostics.update({"cache_hit": True, "path": "cache", "candidates": len(cached)})
|
||||
return list(cached), diagnostics
|
||||
|
||||
query_tokens = _tokenize(normalized_query)
|
||||
exact_ids = set(self._exact_index.get(normalized_query, set()))
|
||||
token_sets: List[set[str]] = []
|
||||
prefix_sets: List[set[str]] = []
|
||||
for token in query_tokens:
|
||||
exact_token_ids = set(self._token_index.get(token, set()))
|
||||
prefix_ids = set(self._prefix_index.get(token, set())) if len(token) >= self.prefix_min_length else set()
|
||||
if exact_token_ids:
|
||||
token_sets.append(exact_token_ids)
|
||||
if prefix_ids:
|
||||
prefix_sets.append(prefix_ids)
|
||||
|
||||
candidate_ids: set[str] = set(exact_ids)
|
||||
if token_sets:
|
||||
intersected = set.intersection(*token_sets)
|
||||
candidate_ids.update(intersected if intersected else set().union(*token_sets))
|
||||
if prefix_sets:
|
||||
candidate_ids.update(set().union(*prefix_sets))
|
||||
|
||||
diagnostics["path"] = "index"
|
||||
|
||||
if not candidate_ids:
|
||||
diagnostics["path"] = "secondary_scan"
|
||||
candidate_ids = self._secondary_scan(normalized_query, limit)
|
||||
|
||||
diagnostics["candidates"] = len(candidate_ids)
|
||||
|
||||
scored: List[Tuple[float, int, int, str]] = []
|
||||
for node_id in candidate_ids:
|
||||
document = self._documents.get(node_id)
|
||||
if document is None or not self._passes_filters(document, filters):
|
||||
continue
|
||||
score = self._score_document(document, normalized_query, query_tokens)
|
||||
if score <= 0:
|
||||
continue
|
||||
token_hits = sum(1 for token in query_tokens if token in document.tokens)
|
||||
exactness = 1 if normalized_query == document.normalized_id or normalized_query in document.exact_terms else 0
|
||||
scored.append((score, exactness, token_hits, node_id))
|
||||
|
||||
top_matches = heapq.nlargest(limit, scored, key=lambda item: (item[0], item[1], item[2], item[3]))
|
||||
results = [(node_id, round(score, 4)) for score, _, _, node_id in top_matches]
|
||||
self._store_cache(cache_key, results)
|
||||
return results, diagnostics
|
||||
|
||||
def _secondary_scan(self, normalized_query: str, limit: int) -> set[str]:
|
||||
matches: set[str] = set()
|
||||
max_hits = max(limit * 20, 200)
|
||||
scanned = 0
|
||||
for node_id in self._ordered_node_ids:
|
||||
if scanned >= self.secondary_scan_limit or len(matches) >= max_hits:
|
||||
break
|
||||
scanned += 1
|
||||
document = self._documents.get(node_id)
|
||||
if document is None:
|
||||
continue
|
||||
if normalized_query in document.primary_text or normalized_query in document.secondary_text:
|
||||
matches.add(node_id)
|
||||
return matches
|
||||
|
||||
def _score_document(
|
||||
self,
|
||||
document: IndexedNodeDocument,
|
||||
normalized_query: str,
|
||||
query_tokens: Tuple[str, ...],
|
||||
) -> float:
|
||||
score = 0.0
|
||||
if normalized_query == document.normalized_id:
|
||||
score = max(score, 140.0)
|
||||
elif normalized_query in document.exact_terms:
|
||||
score = max(score, 120.0)
|
||||
|
||||
if normalized_query and normalized_query in document.primary_text:
|
||||
score = max(score, 78.0 + min(len(normalized_query), 24) / 10.0)
|
||||
elif normalized_query and normalized_query in document.secondary_text:
|
||||
score = max(score, 26.0 + min(len(normalized_query), 24) / 20.0)
|
||||
|
||||
token_hits = 0
|
||||
prefix_hits = 0
|
||||
for token in query_tokens:
|
||||
if token in document.tokens:
|
||||
token_hits += 1
|
||||
elif len(token) >= self.prefix_min_length and any(candidate.startswith(token) for candidate in document.tokens):
|
||||
prefix_hits += 1
|
||||
|
||||
score += token_hits * 18.0
|
||||
score += prefix_hits * 10.0
|
||||
|
||||
if len(query_tokens) > 1 and token_hits:
|
||||
score += token_hits * 4.0
|
||||
|
||||
return score
|
||||
|
||||
def _passes_filters(self, document: IndexedNodeDocument, filters: Dict[str, Any]) -> bool:
|
||||
filter_type = filters.get("type") or filters.get("node_type")
|
||||
if filter_type and document.node_type != str(filter_type):
|
||||
return False
|
||||
|
||||
min_confidence = _coerce_float(filters.get("min_confidence"))
|
||||
if min_confidence is not None:
|
||||
if document.confidence is None or document.confidence < min_confidence:
|
||||
return False
|
||||
|
||||
tags_filter = filters.get("tags")
|
||||
if tags_filter:
|
||||
if isinstance(tags_filter, str):
|
||||
required_tags = {_normalize_text(tags_filter)}
|
||||
else:
|
||||
required_tags = {
|
||||
normalized
|
||||
for normalized in (_normalize_text(tag) for tag in tags_filter)
|
||||
if normalized
|
||||
}
|
||||
if required_tags and not required_tags.issubset(set(document.tags)):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _cache_key(
|
||||
self,
|
||||
normalized_query: str,
|
||||
limit: int,
|
||||
filters: Dict[str, Any],
|
||||
) -> Tuple[Any, ...]:
|
||||
serialized_filters: List[Tuple[str, Any]] = []
|
||||
for key in sorted(filters.keys()):
|
||||
value = filters[key]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
|
||||
else:
|
||||
serialized_filters.append((key, str(value)))
|
||||
return normalized_query, limit, tuple(serialized_filters)
|
||||
|
||||
def _store_cache(self, cache_key: Tuple[Any, ...], results: List[Tuple[str, float]]) -> None:
|
||||
self._cache[cache_key] = list(results)
|
||||
self._cache.move_to_end(cache_key)
|
||||
while len(self._cache) > self.cache_size:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
def _build_document(self, node: Dict[str, Any]) -> IndexedNodeDocument:
|
||||
node_id = str(node.get("id", "")).strip()
|
||||
node_type = str(node.get("type", "entity"))
|
||||
properties = dict(node.get("properties", {}) or {})
|
||||
|
||||
primary_terms: List[str] = []
|
||||
for candidate in (node_id, node.get("content", "")):
|
||||
normalized = _normalize_text(candidate)
|
||||
if normalized:
|
||||
primary_terms.append(normalized)
|
||||
|
||||
for alias_key in _CURATED_ALIAS_KEYS:
|
||||
_collect_text_fragments(properties.get(alias_key), primary_terms, limit=32)
|
||||
|
||||
deduped_primary_terms = tuple(dict.fromkeys(term for term in primary_terms if term))
|
||||
primary_text = " ".join(deduped_primary_terms)
|
||||
tokens = frozenset(_tokenize(primary_text))
|
||||
|
||||
secondary_fragments: List[str] = []
|
||||
for key, value in properties.items():
|
||||
if key in _CURATED_ALIAS_KEYS or key in {"content", "valid_from", "valid_until"}:
|
||||
continue
|
||||
_collect_text_fragments(value, secondary_fragments, limit=48)
|
||||
if len(secondary_fragments) >= 48:
|
||||
break
|
||||
|
||||
secondary_text = " ".join(dict.fromkeys(fragment for fragment in secondary_fragments if fragment))
|
||||
confidence = _coerce_float(properties.get("confidence"))
|
||||
|
||||
raw_tags = properties.get("tags") or []
|
||||
if isinstance(raw_tags, str):
|
||||
raw_tags = [raw_tags]
|
||||
tags = tuple(
|
||||
dict.fromkeys(
|
||||
normalized for normalized in (_normalize_text(tag) for tag in raw_tags) if normalized
|
||||
)
|
||||
)
|
||||
|
||||
return IndexedNodeDocument(
|
||||
node_id=node_id,
|
||||
normalized_id=_normalize_text(node_id),
|
||||
node_type=node_type,
|
||||
exact_terms=frozenset(deduped_primary_terms),
|
||||
tokens=tokens,
|
||||
primary_text=primary_text,
|
||||
secondary_text=secondary_text,
|
||||
confidence=confidence,
|
||||
tags=tags,
|
||||
)
|
||||
+100
-58
@@ -4,12 +4,15 @@ Semantica Explorer session helpers.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
||||
from .search_index import GraphSearchIndex
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
@@ -28,6 +31,8 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphSession:
|
||||
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
|
||||
@@ -35,6 +40,7 @@ class GraphSession:
|
||||
def __init__(self, graph: ContextGraph) -> None:
|
||||
self.graph = graph
|
||||
self._lock = threading.RLock()
|
||||
self._search_index = GraphSearchIndex()
|
||||
|
||||
self.annotations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -46,6 +52,7 @@ class GraphSession:
|
||||
self._similarity: Any = None
|
||||
self._link_predictor: Any = None
|
||||
self._validator: Any = None
|
||||
self.rebuild_search_index()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "GraphSession":
|
||||
@@ -390,6 +397,28 @@ class GraphSession:
|
||||
with self._lock:
|
||||
return self.graph.get_neighbors(node_id, hops=depth)
|
||||
|
||||
def rebuild_search_index(self) -> None:
|
||||
with self._lock:
|
||||
normalized_nodes = [
|
||||
self.normalize_node(node.to_dict())
|
||||
for node in self.graph.nodes.values()
|
||||
if node is not None
|
||||
]
|
||||
self._search_index.rebuild(normalized_nodes)
|
||||
|
||||
def handle_graph_mutation(self, event_type: str, entity_id: str, payload: Dict[str, Any]) -> None:
|
||||
normalized_event = str(event_type or "").upper()
|
||||
if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
|
||||
normalized_node = self.normalize_node(payload or {})
|
||||
if normalized_node.get("id"):
|
||||
with self._lock:
|
||||
self._search_index.upsert(normalized_node)
|
||||
elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
|
||||
with self._lock:
|
||||
self._search_index.remove(str(entity_id))
|
||||
elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
|
||||
self.rebuild_search_index()
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
@@ -397,64 +426,34 @@ class GraphSession:
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
filters = filters or {}
|
||||
try:
|
||||
with self._lock:
|
||||
raw = self.graph.query(query)[:limit]
|
||||
except Exception:
|
||||
raw = []
|
||||
started_at = time.perf_counter()
|
||||
matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
|
||||
|
||||
if not raw:
|
||||
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
|
||||
scored = []
|
||||
lowered_query = query.lower().strip()
|
||||
for node in nodes:
|
||||
haystacks = [
|
||||
str(node.get("id", "")),
|
||||
str(node.get("content", "")),
|
||||
json.dumps(node.get("properties", {}), default=str),
|
||||
]
|
||||
best_score = 0.0
|
||||
for haystack in haystacks:
|
||||
lowered = haystack.lower()
|
||||
if lowered == lowered_query:
|
||||
best_score = max(best_score, 1.0)
|
||||
elif lowered_query in lowered:
|
||||
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
|
||||
if best_score > 0:
|
||||
scored.append({"node": node, "score": round(best_score, 4)})
|
||||
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
|
||||
|
||||
normalized = []
|
||||
for result in raw:
|
||||
result_node = result.get("node", {})
|
||||
node = (
|
||||
self.normalize_node(result_node)
|
||||
if "properties" in result_node or "metadata" in result_node or "content" in result_node
|
||||
else result_node
|
||||
)
|
||||
|
||||
filter_type = filters.get("type") or filters.get("node_type")
|
||||
if filter_type and node["type"] != filter_type:
|
||||
continue
|
||||
|
||||
min_confidence = self._coerce_float(filters.get("min_confidence"))
|
||||
node_confidence = self._coerce_float(node["properties"].get("confidence"))
|
||||
if min_confidence is not None and (
|
||||
node_confidence is None or node_confidence < min_confidence
|
||||
):
|
||||
continue
|
||||
|
||||
tags_filter = filters.get("tags")
|
||||
if tags_filter:
|
||||
node_tags = node["properties"].get("tags") or []
|
||||
if isinstance(node_tags, str):
|
||||
node_tags = [node_tags]
|
||||
if not set(tags_filter).issubset(set(node_tags)):
|
||||
normalized_results: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for node_id, score in matches:
|
||||
raw_node = self.graph.find_node(node_id)
|
||||
if raw_node is None:
|
||||
continue
|
||||
node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
|
||||
normalized_results.append(
|
||||
{
|
||||
"node": self.normalize_node(node_payload),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
|
||||
normalized.append({"node": node, "score": result.get("score", 0.0)})
|
||||
|
||||
return normalized[:limit]
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
logger.debug(
|
||||
"Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
|
||||
query,
|
||||
limit,
|
||||
diagnostics.get("cache_hit"),
|
||||
diagnostics.get("path"),
|
||||
diagnostics.get("candidates"),
|
||||
duration_ms,
|
||||
)
|
||||
return normalized_results[:limit]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
@@ -594,8 +593,51 @@ class GraphSession:
|
||||
|
||||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||||
with self._lock:
|
||||
return self.graph.add_nodes(nodes)
|
||||
added = self.graph.add_nodes(nodes)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
self.rebuild_search_index()
|
||||
return added
|
||||
|
||||
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
|
||||
with self._lock:
|
||||
return self.graph.add_edges(edges)
|
||||
added = self.graph.add_edges(edges)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
self.rebuild_search_index()
|
||||
return added
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
node_id: str,
|
||||
node_type: str,
|
||||
content: Optional[str] = None,
|
||||
**properties: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
added = self.graph.add_node(node_id, node_type, content=content, **properties)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
normalized = self.get_node(node_id)
|
||||
if normalized is not None:
|
||||
self._search_index.upsert(normalized)
|
||||
return added
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
edge_type: str = "related_to",
|
||||
weight: float = 1.0,
|
||||
**properties: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
added = self.graph.add_edge(
|
||||
source_id,
|
||||
target_id,
|
||||
edge_type=edge_type,
|
||||
weight=weight,
|
||||
**properties,
|
||||
)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
return added
|
||||
|
||||
@@ -328,6 +328,18 @@ 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.
|
||||
@@ -342,6 +354,7 @@ class OWLExporter:
|
||||
Returns:
|
||||
String containing OWL Turtle serialization
|
||||
"""
|
||||
esc = self._escape_ttl_str
|
||||
ontology_uri = ontology.get("uri") or self.ontology_uri
|
||||
ontology_name = ontology.get("name", "SemanticaOntology")
|
||||
version = ontology.get("version") or self.version
|
||||
@@ -357,63 +370,73 @@ class OWLExporter:
|
||||
lines.append("")
|
||||
|
||||
# Ontology declaration
|
||||
lines.append(f"<{ontology_uri}> a owl:Ontology ;")
|
||||
lines.append(f' rdfs:label "{ontology_name}" ;')
|
||||
lines.append(f' owl:versionInfo "{version}" .')
|
||||
if ontology.get("description"):
|
||||
lines.append(f' rdfs:comment "{ontology.get("description")}" ;')
|
||||
onto_predicates = [
|
||||
f'rdfs:label "{esc(ontology_name)}"',
|
||||
f'owl:versionInfo "{esc(version)}"',
|
||||
]
|
||||
description = ontology.get("description")
|
||||
if description:
|
||||
onto_predicates.append(f'rdfs:comment "{esc(description)}"')
|
||||
lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
|
||||
lines.append("")
|
||||
|
||||
# Classes
|
||||
classes = ontology.get("classes", [])
|
||||
for cls in classes:
|
||||
for cls in ontology.get("classes", []):
|
||||
class_uri = cls.get("uri") or cls.get("id", "")
|
||||
class_name = cls.get("name") or cls.get("label", "")
|
||||
|
||||
lines.append(f"<{class_uri}> a owl:Class ;")
|
||||
lines.append(f' rdfs:label "{class_name}" .')
|
||||
|
||||
if cls.get("comment"):
|
||||
lines.append(f' rdfs:comment "{cls.get("comment")}" ;')
|
||||
|
||||
if cls.get("subClassOf"):
|
||||
parent = cls.get("subClassOf")
|
||||
lines.append(f" rdfs:subClassOf <{parent}> ;")
|
||||
|
||||
# Remove trailing semicolon and add period
|
||||
if lines[-1].endswith(" ;"):
|
||||
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||
else:
|
||||
lines.append(" .")
|
||||
predicates = [f'rdfs:label "{esc(class_name)}"']
|
||||
comment = cls.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
sub_class = cls.get("subClassOf")
|
||||
if sub_class:
|
||||
predicates.append(f"rdfs:subClassOf <{sub_class}>")
|
||||
equiv = cls.get("equivalentClass")
|
||||
if equiv:
|
||||
predicates.append(f"owl:equivalentClass <{equiv}>")
|
||||
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
|
||||
lines.append("")
|
||||
|
||||
# Object properties
|
||||
object_properties = ontology.get("object_properties", [])
|
||||
for prop in object_properties:
|
||||
for prop in ontology.get("object_properties", []):
|
||||
prop_uri = prop.get("uri") or prop.get("id", "")
|
||||
prop_name = prop.get("name") or prop.get("label", "")
|
||||
|
||||
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
|
||||
lines.append(f' rdfs:label "{prop_name}" .')
|
||||
|
||||
if prop.get("domain"):
|
||||
domain = prop.get("domain")
|
||||
predicates = [f'rdfs:label "{esc(prop_name)}"']
|
||||
comment = prop.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
domain = prop.get("domain")
|
||||
if domain:
|
||||
if isinstance(domain, list):
|
||||
for d in domain:
|
||||
lines.append(f" rdfs:domain <{d}> ;")
|
||||
predicates.append(f"rdfs:domain <{d}>")
|
||||
else:
|
||||
lines.append(f" rdfs:domain <{domain}> ;")
|
||||
|
||||
if prop.get("range"):
|
||||
range_val = prop.get("range")
|
||||
predicates.append(f"rdfs:domain <{domain}>")
|
||||
range_val = prop.get("range")
|
||||
if range_val:
|
||||
if isinstance(range_val, list):
|
||||
for r in range_val:
|
||||
lines.append(f" rdfs:range <{r}> ;")
|
||||
predicates.append(f"rdfs:range <{r}>")
|
||||
else:
|
||||
lines.append(f" rdfs:range <{range_val}> ;")
|
||||
predicates.append(f"rdfs:range <{range_val}>")
|
||||
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
|
||||
lines.append("")
|
||||
|
||||
if lines[-1].endswith(" ;"):
|
||||
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||
# Data properties
|
||||
for prop in ontology.get("data_properties", []):
|
||||
prop_uri = prop.get("uri") or prop.get("id", "")
|
||||
prop_name = prop.get("name") or prop.get("label", "")
|
||||
predicates = [f'rdfs:label "{esc(prop_name)}"']
|
||||
comment = prop.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
domain = prop.get("domain")
|
||||
if domain:
|
||||
predicates.append(f"rdfs:domain <{domain}>")
|
||||
range_type = prop.get("range")
|
||||
if range_type:
|
||||
predicates.append(f"rdfs:range xsd:{range_type}")
|
||||
lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -126,12 +126,14 @@ from .temporal_query import (
|
||||
TemporalPatternDetector,
|
||||
TemporalVersionManager,
|
||||
)
|
||||
from .knowledge_graph import KnowledgeGraph
|
||||
from .temporal_model import BiTemporalFact, TemporalBound
|
||||
from .temporal_normalizer import TemporalNormalizer
|
||||
from .temporal_query_rewriter import TemporalQueryRewriter, TemporalQueryResult
|
||||
|
||||
__all__ = [
|
||||
# Core Classes
|
||||
"KnowledgeGraph",
|
||||
"GraphBuilder",
|
||||
"GraphBuilderWithProvenance",
|
||||
"EntityResolver",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
KnowledgeGraph dataclass — canonical in-memory representation.
|
||||
|
||||
This is the formal type produced by the Semantica KG pipeline and consumed
|
||||
by visualizers, exporters, and other downstream components. It is a thin,
|
||||
immutable-friendly wrapper around three plain collections so that isinstance
|
||||
checks, type hints, and IDEs can surface the type rather than relying on
|
||||
bare dicts.
|
||||
|
||||
Keeping this in its own file avoids circular imports between the kg and
|
||||
visualization sub-packages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeGraph:
|
||||
"""
|
||||
Canonical in-memory knowledge graph.
|
||||
|
||||
Attributes:
|
||||
entities: List of entity dicts with at minimum ``id`` and ``type`` keys.
|
||||
relationships: List of relationship dicts with at minimum ``source``,
|
||||
``target``, and ``type`` keys.
|
||||
metadata: Arbitrary graph-level metadata (e.g. build timestamps,
|
||||
entity-resolution flags).
|
||||
"""
|
||||
|
||||
entities: List[Dict[str, Any]] = field(default_factory=list)
|
||||
relationships: List[Dict[str, Any]] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of entities (mirrors the most common 'size' query)."""
|
||||
return len(self.entities)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.entities or self.relationships)
|
||||
+38
-19
@@ -104,7 +104,8 @@ class PathFinder:
|
||||
source: str,
|
||||
target: str,
|
||||
weight_attribute: str = "weight",
|
||||
default_weight: float = 1.0
|
||||
default_weight: float = 1.0,
|
||||
directed: bool = True
|
||||
) -> List[str]:
|
||||
"""
|
||||
Find shortest path using Dijkstra's algorithm.
|
||||
@@ -125,32 +126,34 @@ class PathFinder:
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
|
||||
|
||||
|
||||
# Validate nodes exist
|
||||
if not self._node_exists(graph, source):
|
||||
raise ValueError(f"Source node {source} not found")
|
||||
if not self._node_exists(graph, target):
|
||||
raise ValueError(f"Target node {target} not found")
|
||||
|
||||
|
||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
||||
|
||||
# Dijkstra's algorithm
|
||||
distances = {source: 0.0}
|
||||
previous = {}
|
||||
priority_queue = [(0.0, source)]
|
||||
visited = set()
|
||||
|
||||
|
||||
while priority_queue:
|
||||
current_distance, current_node = heapq.heappop(priority_queue)
|
||||
|
||||
|
||||
if current_node in visited:
|
||||
continue
|
||||
|
||||
|
||||
visited.add(current_node)
|
||||
|
||||
|
||||
if current_node == target:
|
||||
break
|
||||
|
||||
|
||||
# Explore neighbors
|
||||
for neighbor, edge_data in self._get_neighbors(graph, current_node):
|
||||
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
|
||||
if neighbor in visited:
|
||||
continue
|
||||
|
||||
@@ -350,44 +353,48 @@ class PathFinder:
|
||||
self,
|
||||
graph: Any,
|
||||
source: str,
|
||||
target: str
|
||||
target: str,
|
||||
directed: bool = True
|
||||
) -> List[str]:
|
||||
"""
|
||||
Find shortest path using BFS (unweighted).
|
||||
|
||||
|
||||
Args:
|
||||
graph: Graph object (NetworkX or similar)
|
||||
source: Source node ID
|
||||
target: Target node ID
|
||||
|
||||
directed: If False, treat the graph as undirected for traversal
|
||||
|
||||
Returns:
|
||||
List of node IDs representing the shortest path
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: If source or target not found
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
|
||||
|
||||
|
||||
# Validate nodes exist
|
||||
if not self._node_exists(graph, source):
|
||||
raise ValueError(f"Source node {source} not found")
|
||||
if not self._node_exists(graph, target):
|
||||
raise ValueError(f"Target node {target} not found")
|
||||
|
||||
|
||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
||||
|
||||
# BFS algorithm
|
||||
queue = deque([(source, [source])])
|
||||
visited = {source}
|
||||
|
||||
|
||||
while queue:
|
||||
current, path = queue.popleft()
|
||||
|
||||
|
||||
if current == target:
|
||||
self.logger.info(f"Found BFS path of length {len(path)}")
|
||||
return path
|
||||
|
||||
|
||||
# Explore neighbors
|
||||
for neighbor, _ in self._get_neighbors(graph, current):
|
||||
for neighbor, _ in self._get_neighbors(traversal_graph, current):
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor]))
|
||||
@@ -564,6 +571,18 @@ class PathFinder:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _make_undirected_view(self, graph: Any) -> Any:
|
||||
"""Return an undirected view of the graph for bidirectional traversal.
|
||||
|
||||
For NetworkX directed graphs this calls ``to_undirected()``, which
|
||||
preserves all edge attributes. For graph types that have no such
|
||||
method the original object is returned as a fallback — callers that
|
||||
already expose undirected neighbors will still work correctly.
|
||||
"""
|
||||
if hasattr(graph, "to_undirected"):
|
||||
return graph.to_undirected()
|
||||
return graph
|
||||
|
||||
def _get_neighbors(self, graph: Any, node: str) -> List[Tuple[str, Any]]:
|
||||
"""Get neighbors of a node with edge data."""
|
||||
neighbors = []
|
||||
|
||||
@@ -397,6 +397,7 @@ class BaseProvider:
|
||||
create_kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = client.chat.completions.create(**create_kwargs)
|
||||
verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
|
||||
if verbose_mode:
|
||||
import sys
|
||||
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
|
||||
@@ -939,20 +940,22 @@ class DeepSeekProvider(BaseProvider):
|
||||
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key or config.get_api_key("deepseek")
|
||||
self.base_url = "https://api.deepseek.com/v1"
|
||||
self.model = model
|
||||
self.base_url = "https://api.deepseek.com/v1"
|
||||
self.client = None
|
||||
self._init_client()
|
||||
|
||||
def _init_client(self):
|
||||
try:
|
||||
import deepseek # type: ignore[import-untyped]
|
||||
from openai import OpenAI
|
||||
|
||||
if self.api_key:
|
||||
self.client = deepseek.Client(api_key=self.api_key)
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
except (ImportError, OSError):
|
||||
self.client = None
|
||||
self.logger.warning(
|
||||
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
|
||||
"openai library not installed. Install with: pip install semantica[llm-openai]"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
|
||||
+15
-3
@@ -188,10 +188,22 @@ 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 (
|
||||
normalized_path in ("", ".")
|
||||
or os.path.isabs(normalized_path)
|
||||
os.path.isabs(normalized_path)
|
||||
or normalized_path == ".."
|
||||
or normalized_path.startswith(".." + os.sep)
|
||||
):
|
||||
@@ -200,7 +212,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()
|
||||
|
||||
@@ -562,3 +562,25 @@ def retry_on_error(
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def classify_path_distance(hop_count: int) -> str:
|
||||
"""Classify a path hop count into a human-readable distance band.
|
||||
|
||||
Bands:
|
||||
"direct" — 0–1 hops (single edge or self)
|
||||
"near" — 2–3 hops (closely related)
|
||||
"mid-range" — 4–6 hops (reachable but separated)
|
||||
"distant" — 7+ hops (weakly coupled)
|
||||
|
||||
This is the single source of truth for distance-band thresholds used by
|
||||
both the Explorer API (PathResponse.distance_band) and the KGVisualizer
|
||||
(highlight_path edge styling).
|
||||
"""
|
||||
if hop_count <= 1:
|
||||
return "direct"
|
||||
if hop_count <= 3:
|
||||
return "near"
|
||||
if hop_count <= 6:
|
||||
return "mid-range"
|
||||
return "distant"
|
||||
|
||||
@@ -53,6 +53,15 @@ except (ImportError, OSError):
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
# Optional import — keeps the visualizer usable even if the kg sub-package
|
||||
# is not installed, and avoids circular-import risk at module level.
|
||||
try:
|
||||
from ..kg.knowledge_graph import KnowledgeGraph as _KnowledgeGraph
|
||||
except Exception: # pragma: no cover
|
||||
_KnowledgeGraph = None # type: ignore[assignment,misc]
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .utils.color_schemes import ColorPalette, ColorScheme
|
||||
from .utils.export_formats import (
|
||||
@@ -118,18 +127,45 @@ class KGVisualizer:
|
||||
"Install with: pip install plotly"
|
||||
)
|
||||
|
||||
def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
|
||||
def _convert_knowledge_graph(self, kg: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize graph input to the expected dict format.
|
||||
Convert a KnowledgeGraph instance to the internal dict format.
|
||||
|
||||
Accepts either:
|
||||
- A dict with "entities" and "relationships" keys (canonical format)
|
||||
- Any object that exposes .entities and .relationships attributes
|
||||
(e.g. a KnowledgeGraph dataclass returned by GraphBuilder.build())
|
||||
Non-mutating. Preserves node types, labels, properties, edge types,
|
||||
weights, and direction.
|
||||
|
||||
Args:
|
||||
kg: A ``KnowledgeGraph`` instance.
|
||||
|
||||
Returns:
|
||||
Dict with "entities", "relationships", and "metadata" keys.
|
||||
"""
|
||||
entities = getattr(kg, "entities", None) or []
|
||||
relationships = getattr(kg, "relationships", None) or []
|
||||
metadata = getattr(kg, "metadata", None) or {}
|
||||
return {
|
||||
"entities": list(entities),
|
||||
"relationships": list(relationships),
|
||||
"metadata": dict(metadata),
|
||||
}
|
||||
|
||||
def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Normalize graph input to the expected dict format.
|
||||
|
||||
Accepts:
|
||||
- A ``KnowledgeGraph`` instance (routed through ``_convert_knowledge_graph``)
|
||||
- A dict with "entities" and "relationships" keys (canonical format)
|
||||
- Any object that exposes .entities and .relationships attributes
|
||||
(duck-typed, e.g. custom dataclasses)
|
||||
|
||||
Returns:
|
||||
Dict with "entities", "relationships", and "metadata" keys.
|
||||
"""
|
||||
# Explicit fast-path for the formal KnowledgeGraph type
|
||||
if _KnowledgeGraph is not None and isinstance(graph, _KnowledgeGraph):
|
||||
return self._convert_knowledge_graph(graph)
|
||||
|
||||
if isinstance(graph, dict):
|
||||
return graph
|
||||
|
||||
@@ -156,6 +192,7 @@ class KGVisualizer:
|
||||
node_color_by: str = "type",
|
||||
node_size_by: Optional[str] = None,
|
||||
hover_data: Optional[List[str]] = None,
|
||||
highlight_path: Optional[List[str]] = None,
|
||||
**options,
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
@@ -177,6 +214,9 @@ 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:
|
||||
@@ -226,13 +266,14 @@ class KGVisualizer:
|
||||
tracking_id, message="Generating visualization..."
|
||||
)
|
||||
result = self._visualize_network_plotly(
|
||||
nodes,
|
||||
edges,
|
||||
output,
|
||||
file_path,
|
||||
nodes,
|
||||
edges,
|
||||
output,
|
||||
file_path,
|
||||
node_color_by=node_color_by,
|
||||
node_size_by=node_size_by,
|
||||
hover_data=hover_data,
|
||||
highlight_path=highlight_path,
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -529,6 +570,21 @@ class KGVisualizer:
|
||||
|
||||
return edges
|
||||
|
||||
@staticmethod
|
||||
def _path_edge_style(distance_band: str) -> Tuple[float, float]:
|
||||
"""Return (opacity, width) for a path edge based on its distance band.
|
||||
|
||||
Bands come from ``classify_path_distance`` in ``utils.helpers`` — the
|
||||
single source of truth for hop-count thresholds.
|
||||
"""
|
||||
if distance_band == "direct":
|
||||
return (1.0, 4.0)
|
||||
if distance_band == "near":
|
||||
return (0.85, 3.0)
|
||||
if distance_band == "mid-range":
|
||||
return (0.6, 2.0)
|
||||
return (0.35, 1.5) # "distant"
|
||||
|
||||
def _visualize_network_plotly(
|
||||
self,
|
||||
nodes: List[Dict[str, Any]],
|
||||
@@ -538,6 +594,7 @@ class KGVisualizer:
|
||||
node_color_by: str = "type",
|
||||
node_size_by: Optional[str] = None,
|
||||
hover_data: Optional[List[str]] = None,
|
||||
highlight_path: Optional[List[str]] = None,
|
||||
**options,
|
||||
) -> Optional[Any]:
|
||||
"""Create Plotly network visualization."""
|
||||
@@ -640,47 +697,73 @@ class KGVisualizer:
|
||||
|
||||
node_text.append(text)
|
||||
|
||||
# Prepare edge traces
|
||||
edge_x = []
|
||||
edge_y = []
|
||||
|
||||
# Build path edge lookup for highlight_path support
|
||||
path_edge_set: set = set()
|
||||
path_distance_band = "direct"
|
||||
if highlight_path and len(highlight_path) >= 2:
|
||||
path_hop_count = len(highlight_path) - 1
|
||||
path_distance_band = classify_path_distance(path_hop_count)
|
||||
# Only add the directed edges that actually form the path (A→B, not B→A).
|
||||
# Adding the reverse would incorrectly highlight unrelated back-edges.
|
||||
for i in range(path_hop_count):
|
||||
path_edge_set.add((highlight_path[i], highlight_path[i + 1]))
|
||||
# Warn if any path node has no layout position (silent highlight failure).
|
||||
missing = [n for n in highlight_path if n not in pos]
|
||||
if missing:
|
||||
self.logger.warning(
|
||||
"highlight_path contains node IDs not found in the graph: %s",
|
||||
missing,
|
||||
)
|
||||
|
||||
path_opacity, path_width = self._path_edge_style(path_distance_band)
|
||||
|
||||
# Prepare edge traces — split into background (non-path) and path edges
|
||||
edge_x: List = []
|
||||
edge_y: List = []
|
||||
path_edge_x: List = []
|
||||
path_edge_y: List = []
|
||||
|
||||
# Prepare edge label traces and annotations (for arrows)
|
||||
edge_label_x = []
|
||||
edge_label_y = []
|
||||
edge_label_text = []
|
||||
annotations = []
|
||||
|
||||
|
||||
# Limit detailed edge rendering for performance if graph is too large
|
||||
show_detailed_edges = len(edges) < 500
|
||||
|
||||
|
||||
for edge in edges:
|
||||
source_pos = pos.get(edge["source"])
|
||||
target_pos = pos.get(edge["target"])
|
||||
if source_pos and target_pos:
|
||||
x0, y0 = source_pos
|
||||
x1, y1 = target_pos
|
||||
edge_x.extend([x0, x1, None])
|
||||
edge_y.extend([y0, y1, None])
|
||||
|
||||
|
||||
is_path_edge = (edge["source"], edge["target"]) in path_edge_set
|
||||
if is_path_edge:
|
||||
path_edge_x.extend([x0, x1, None])
|
||||
path_edge_y.extend([y0, y1, None])
|
||||
else:
|
||||
edge_x.extend([x0, x1, None])
|
||||
edge_y.extend([y0, y1, None])
|
||||
|
||||
if show_detailed_edges:
|
||||
# Calculate midpoint for label
|
||||
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
|
||||
|
||||
if edge.get("label"):
|
||||
edge_label_x.append(mx)
|
||||
edge_label_y.append(my)
|
||||
edge_label_text.append(edge["label"])
|
||||
|
||||
|
||||
# Add arrow annotation
|
||||
# Adjust arrow to point slightly before the node to avoid overlap with node marker
|
||||
# This is approximate; precise calculation requires node size
|
||||
annotations.append(
|
||||
dict(
|
||||
ax=x0, ay=y0, axref='x', ayref='y',
|
||||
x=x1, y=y1, xref='x', yref='y',
|
||||
arrowhead=2, arrowsize=1, arrowwidth=1,
|
||||
arrowcolor="#888", opacity=0.6,
|
||||
standoff=15 # Distance from target node
|
||||
standoff=15
|
||||
)
|
||||
)
|
||||
|
||||
@@ -693,9 +776,22 @@ class KGVisualizer:
|
||||
showlegend=False,
|
||||
opacity=0.5
|
||||
)
|
||||
|
||||
|
||||
traces = [edge_trace]
|
||||
|
||||
|
||||
# Overlay highlighted path edges with distance-aware styling
|
||||
if path_edge_x:
|
||||
path_trace = go.Scatter(
|
||||
x=path_edge_x,
|
||||
y=path_edge_y,
|
||||
line=dict(width=path_width, color="#e05c00"),
|
||||
hoverinfo="none",
|
||||
mode="lines",
|
||||
showlegend=False,
|
||||
opacity=path_opacity,
|
||||
)
|
||||
traces.append(path_trace)
|
||||
|
||||
if show_detailed_edges and edge_label_text:
|
||||
edge_label_trace = go.Scatter(
|
||||
x=edge_label_x,
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
@@ -35,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
|
||||
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
|
||||
graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30)
|
||||
graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60)
|
||||
graph.add_node(
|
||||
"metformin",
|
||||
node_type="drug",
|
||||
content="Metformin",
|
||||
aliases=["Glucophage"],
|
||||
confidence="0.97",
|
||||
tags=["drug", "featured"],
|
||||
x=22,
|
||||
y=33,
|
||||
)
|
||||
graph.add_node(
|
||||
"decision_1",
|
||||
node_type="decision",
|
||||
@@ -243,6 +254,74 @@ class TestSearchAndStats:
|
||||
assert payload["total"] >= 1
|
||||
assert all(item["node"]["type"] == "language" for item in payload["results"])
|
||||
|
||||
def test_search_exact_and_prefix(self, client):
|
||||
exact_response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "Metformin", "limit": 5},
|
||||
)
|
||||
assert exact_response.status_code == 200
|
||||
exact_payload = exact_response.json()
|
||||
assert exact_payload["results"][0]["node"]["id"] == "metformin"
|
||||
|
||||
prefix_response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "metf", "limit": 5},
|
||||
)
|
||||
assert prefix_response.status_code == 200
|
||||
prefix_payload = prefix_response.json()
|
||||
assert any(item["node"]["id"] == "metformin" for item in prefix_payload["results"])
|
||||
|
||||
def test_search_filters_and_cache_stability(self, client):
|
||||
body = {
|
||||
"query": "framework",
|
||||
"filters": {"type": "decision", "min_confidence": 0.8},
|
||||
"limit": 5,
|
||||
}
|
||||
first_response = client.post("/api/graph/search", json=body)
|
||||
second_response = client.post("/api/graph/search", json=body)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
assert second_response.status_code == 200
|
||||
assert first_response.json() == second_response.json()
|
||||
results = first_response.json()["results"]
|
||||
assert [item["node"]["id"] for item in results] == ["decision_1"]
|
||||
|
||||
def test_search_sees_new_nodes_after_mutation(self, client):
|
||||
session = client.app.state.session
|
||||
assert session.add_node(
|
||||
"metformin_hcl",
|
||||
"drug",
|
||||
content="Metformin Hydrochloride",
|
||||
aliases=["Glucophage XR"],
|
||||
confidence="0.93",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "glucophage", "limit": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result_ids = [item["node"]["id"] for item in response.json()["results"]]
|
||||
assert "metformin" in result_ids
|
||||
assert "metformin_hcl" in result_ids
|
||||
|
||||
def test_search_secondary_scan_fallback_matches_non_curated_properties(self, client):
|
||||
session = client.app.state.session
|
||||
assert session.add_node(
|
||||
"fallback_node",
|
||||
"entity",
|
||||
content="Alpha",
|
||||
description="rareterm",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "rareterm", "limit": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result_ids = [item["node"]["id"] for item in response.json()["results"]]
|
||||
assert "fallback_node" in result_ids
|
||||
|
||||
def test_stats(self, client):
|
||||
response = client.get("/api/graph/stats")
|
||||
assert response.status_code == 200
|
||||
@@ -372,7 +451,7 @@ class TestEnrichment:
|
||||
|
||||
def test_extract(self, client):
|
||||
response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."})
|
||||
assert response.status_code in (200, 422)
|
||||
assert response.status_code in (200, 422, 503)
|
||||
|
||||
def test_link_prediction(self, client):
|
||||
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
|
||||
@@ -638,3 +717,177 @@ class TestGenericGraphFileLoading:
|
||||
assert repeat.status_code == 200
|
||||
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
|
||||
assert repeat_ids == ["edge-alpha", "edge-beta"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bidirectional path-finding tests (issue #469)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_path_session() -> GraphSession:
|
||||
"""Return a GraphSession whose build_graph_dict yields an nx.DiGraph with A→B only.
|
||||
|
||||
GraphSession wraps a ContextGraph (required by create_app), but we patch
|
||||
build_graph_dict so PathFinder receives an actual NetworkX DiGraph — the
|
||||
graph type the Explorer is designed to traverse for path queries.
|
||||
"""
|
||||
cg = ContextGraph(advanced_analytics=False)
|
||||
cg.add_node("A", node_type="entity", content="Node A")
|
||||
cg.add_node("B", node_type="entity", content="Node B")
|
||||
cg.add_edge("A", "B", edge_type="connects")
|
||||
|
||||
session = GraphSession(cg)
|
||||
|
||||
# Patch build_graph_dict to return the directed NetworkX graph that
|
||||
# PathFinder needs. The ContextGraph dict format is not traversable by
|
||||
# PathFinder; this mimics how a KG-backed session would expose the graph.
|
||||
digraph = nx.DiGraph()
|
||||
digraph.add_edge("A", "B")
|
||||
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def path_client():
|
||||
session = _make_path_session()
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestBidirectionalPathRoute:
|
||||
"""API-level tests for directed=true/false on GET /api/graph/node/{id}/path."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# directed=true (default) — existing directed-only behaviour
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_directed_true_forward_path_found(self, path_client):
|
||||
"""A→B exists: forward query with directed=true must succeed."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=true")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["A", "B"]
|
||||
assert body["directed"] is True
|
||||
|
||||
def test_directed_true_reverse_returns_404(self, path_client):
|
||||
"""Only A→B exists: reverse query with directed=true must return 404."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A&directed=true")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_param_reverse_returns_404(self, path_client):
|
||||
"""Omitting directed= must preserve current directed behaviour (404 for reverse)."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# directed=false — new undirected traversal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_directed_false_reverse_path_found(self, path_client):
|
||||
"""directed=false must find B→A even though only A→B exists."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A&directed=false")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_directed_false_forward_path_found(self, path_client):
|
||||
"""directed=false must not break the natural A→B direction."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["A", "B"]
|
||||
assert body["directed"] is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Algorithm variants
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_dijkstra_directed_false_reverse(self, path_client):
|
||||
resp = path_client.get(
|
||||
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=false"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["algorithm"] == "dijkstra"
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_dijkstra_directed_true_reverse_returns_404(self, path_client):
|
||||
resp = path_client.get(
|
||||
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=true"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PathResponse schema
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_response_schema_includes_directed_field(self, path_client):
|
||||
"""PathResponse must always include the directed field."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "directed" in body
|
||||
|
||||
def test_response_directed_reflects_query_param(self, path_client):
|
||||
resp_true = path_client.get("/api/graph/node/A/path?target=B&directed=true")
|
||||
resp_false = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
assert resp_true.json()["directed"] is True
|
||||
assert resp_false.json()["directed"] is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# hop_count and distance_band — issue #472
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_response_includes_hop_count_and_distance_band(self, path_client):
|
||||
"""PathResponse must include hop_count and distance_band fields."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "hop_count" in body
|
||||
assert "distance_band" in body
|
||||
|
||||
def test_one_hop_path_is_direct(self, path_client):
|
||||
"""A single-edge path (1 hop) must return distance_band='direct'."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["hop_count"] == 1
|
||||
assert body["distance_band"] == "direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify_distance unit tests — issue #472
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from semantica.utils.helpers import classify_path_distance
|
||||
|
||||
|
||||
class TestClassifyDistance:
|
||||
"""Unit tests covering all four band boundaries."""
|
||||
|
||||
def test_zero_hops_is_direct(self):
|
||||
assert classify_path_distance(0) == "direct"
|
||||
|
||||
def test_one_hop_is_direct(self):
|
||||
assert classify_path_distance(1) == "direct"
|
||||
|
||||
def test_two_hops_is_near(self):
|
||||
assert classify_path_distance(2) == "near"
|
||||
|
||||
def test_three_hops_is_near(self):
|
||||
assert classify_path_distance(3) == "near"
|
||||
|
||||
def test_four_hops_is_mid_range(self):
|
||||
assert classify_path_distance(4) == "mid-range"
|
||||
|
||||
def test_six_hops_is_mid_range(self):
|
||||
assert classify_path_distance(6) == "mid-range"
|
||||
|
||||
def test_seven_hops_is_distant(self):
|
||||
assert classify_path_distance(7) == "distant"
|
||||
|
||||
def test_large_hop_count_is_distant(self):
|
||||
assert classify_path_distance(20) == "distant"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Unit tests for explorer provenance route helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from semantica.explorer.routes.provenance import _build_provenance, _render_markdown
|
||||
|
||||
|
||||
def _make_session_with_chain() -> SimpleNamespace:
|
||||
"""Build a minimal session-like object for Source -> Intermediate -> node_id."""
|
||||
nodes = {
|
||||
"Source": SimpleNamespace(node_type="entity", content="Source"),
|
||||
"Intermediate": SimpleNamespace(node_type="entity", content="Intermediate"),
|
||||
"node_id": SimpleNamespace(node_type="entity", content="Target"),
|
||||
}
|
||||
edges = [
|
||||
SimpleNamespace(source_id="Source", target_id="Intermediate", edge_type="related_to"),
|
||||
SimpleNamespace(source_id="Intermediate", target_id="node_id", edge_type="related_to"),
|
||||
]
|
||||
graph = SimpleNamespace(nodes=nodes, edges=edges)
|
||||
return SimpleNamespace(graph=graph)
|
||||
|
||||
|
||||
def test_build_provenance_direction_classification_chain():
|
||||
session = _make_session_with_chain()
|
||||
|
||||
data = _build_provenance(session, "node_id")
|
||||
|
||||
node_ids = {node["id"] for node in data["nodes"]}
|
||||
assert "Source" in node_ids
|
||||
assert "Intermediate" in node_ids
|
||||
|
||||
edge_by_pair = {(edge["source"], edge["target"]): edge for edge in data["edges"]}
|
||||
|
||||
assert edge_by_pair[("Intermediate", "node_id")]["direction"] == "upstream"
|
||||
assert edge_by_pair[("Source", "Intermediate")]["direction"] != "downstream"
|
||||
|
||||
|
||||
def test_render_markdown_groups_edges_by_direction():
|
||||
report = {
|
||||
"node_id": "node_id",
|
||||
"label": "Target",
|
||||
"type": "entity",
|
||||
"properties": {},
|
||||
"lineage": {
|
||||
"nodes": [
|
||||
{"id": "Source", "prov_type": "Entity", "label": "Source"},
|
||||
{"id": "Intermediate", "prov_type": "Entity", "label": "Intermediate"},
|
||||
{"id": "node_id", "prov_type": "Entity", "label": "Target"},
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "Intermediate-node_id",
|
||||
"source": "Intermediate",
|
||||
"target": "node_id",
|
||||
"label": "related_to",
|
||||
"direction": "upstream",
|
||||
},
|
||||
{
|
||||
"id": "Source-Intermediate",
|
||||
"source": "Source",
|
||||
"target": "Intermediate",
|
||||
"label": "related_to",
|
||||
"direction": "lateral",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
markdown = _render_markdown(report)
|
||||
|
||||
assert "## Upstream" in markdown
|
||||
assert "## Lateral" in markdown
|
||||
assert "`Intermediate` -[related_to]-> `node_id`" in markdown
|
||||
assert "`Source` -[related_to]-> `Intermediate`" in markdown
|
||||
@@ -0,0 +1,549 @@
|
||||
"""Tests for OWLExporter._export_owl_turtle fixes (issue #478).
|
||||
|
||||
Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended
|
||||
after a closing period).
|
||||
Bug 2: data_properties silently dropped from Turtle output.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from semantica.export import OWLExporter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def exporter():
|
||||
return OWLExporter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_ontology():
|
||||
return {
|
||||
"uri": "http://example.org/onto",
|
||||
"name": "TestOntology",
|
||||
"description": "A test ontology",
|
||||
"classes": [
|
||||
{
|
||||
"uri": "http://example.org/Person",
|
||||
"name": "Person",
|
||||
},
|
||||
{
|
||||
"uri": "http://example.org/Employee",
|
||||
"name": "Employee",
|
||||
"comment": "A person who is employed",
|
||||
"subClassOf": "http://example.org/Person",
|
||||
},
|
||||
{
|
||||
"uri": "http://example.org/Manager",
|
||||
"name": "Manager",
|
||||
"subClassOf": "http://example.org/Employee",
|
||||
"equivalentClass": "http://example.org/Supervisor",
|
||||
},
|
||||
],
|
||||
"object_properties": [
|
||||
{
|
||||
"uri": "http://example.org/worksFor",
|
||||
"name": "worksFor",
|
||||
"domain": "http://example.org/Employee",
|
||||
"range": "http://example.org/Company",
|
||||
},
|
||||
{
|
||||
"uri": "http://example.org/manages",
|
||||
"name": "manages",
|
||||
"comment": "manages a team",
|
||||
"domain": ["http://example.org/Manager"],
|
||||
"range": ["http://example.org/Employee"],
|
||||
},
|
||||
],
|
||||
"data_properties": [
|
||||
{
|
||||
"uri": "http://example.org/hasAge",
|
||||
"name": "hasAge",
|
||||
"domain": "http://example.org/Person",
|
||||
"range": "integer",
|
||||
},
|
||||
{
|
||||
"uri": "http://example.org/hasName",
|
||||
"name": "hasName",
|
||||
"comment": "full name",
|
||||
"domain": "http://example.org/Person",
|
||||
"range": "string",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 1 — valid Turtle syntax
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTurtleSyntaxValidity:
|
||||
"""Every subject block must have exactly one closing period at the end."""
|
||||
|
||||
def _blocks(self, turtle: str) -> list[str]:
|
||||
"""Split output into non-empty logical blocks (separated by blank lines)."""
|
||||
return [b.strip() for b in turtle.split("\n\n") if b.strip()]
|
||||
|
||||
def test_no_triple_after_period(self, exporter, full_ontology):
|
||||
"""No predicate line may appear after a line that ends with ' .'."""
|
||||
turtle = exporter._export_owl_turtle(full_ontology)
|
||||
lines = turtle.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.rstrip()
|
||||
if stripped.endswith(" .") and i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
# next non-blank line must not be a predicate continuation
|
||||
if next_line:
|
||||
assert not next_line.startswith("rdfs:"), (
|
||||
f"Predicate continuation after closing '.' at line {i + 1}: "
|
||||
f"{lines[i]!r} → {lines[i + 1]!r}"
|
||||
)
|
||||
|
||||
def test_each_subject_block_ends_with_period(self, exporter, full_ontology):
|
||||
"""Every subject block (class / property declaration) ends with exactly one '.'."""
|
||||
turtle = exporter._export_owl_turtle(full_ontology)
|
||||
blocks = self._blocks(turtle)
|
||||
# skip the @prefix lines block and ontology declaration
|
||||
subject_blocks = [b for b in blocks if b.startswith("<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
|
||||
@@ -821,3 +821,87 @@ class TestPathFinderEdgeCases:
|
||||
|
||||
paths = self.finder.all_shortest_paths(single_node_graph, "A")
|
||||
assert len(paths) == 0 # No paths to other nodes
|
||||
|
||||
|
||||
class TestBidirectionalPathFinding:
|
||||
"""Tests for the directed=False undirected-traversal mode (issue #469)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.finder = PathFinder()
|
||||
# Single directed edge A → B. Reverse query B → A has no directed path.
|
||||
self.digraph = nx.DiGraph()
|
||||
self.digraph.add_edge("A", "B")
|
||||
|
||||
# --- directed=True (default) preserves existing behaviour ---
|
||||
|
||||
def test_bfs_directed_true_reverse_returns_empty(self):
|
||||
"""B→A should find nothing when directed=True (default)."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=True)
|
||||
assert path == []
|
||||
|
||||
def test_dijkstra_directed_true_reverse_returns_empty(self):
|
||||
"""B→A should find nothing when directed=True (default)."""
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=True)
|
||||
assert path == []
|
||||
|
||||
def test_bfs_directed_true_default_arg(self):
|
||||
"""Omitting directed= should behave the same as directed=True."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A")
|
||||
assert path == []
|
||||
|
||||
def test_dijkstra_directed_true_default_arg(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A")
|
||||
assert path == []
|
||||
|
||||
# --- directed=False finds path against edge orientation ---
|
||||
|
||||
def test_bfs_directed_false_reverse_single_edge(self):
|
||||
"""directed=False must find B→A even though only A→B exists."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=False)
|
||||
assert path == ["B", "A"]
|
||||
|
||||
def test_dijkstra_directed_false_reverse_single_edge(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=False)
|
||||
assert path == ["B", "A"]
|
||||
|
||||
def test_bfs_directed_false_forward_still_works(self):
|
||||
"""directed=False should not break the forward direction."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "A", "B", directed=False)
|
||||
assert path == ["A", "B"]
|
||||
|
||||
def test_dijkstra_directed_false_forward_still_works(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "A", "B", directed=False)
|
||||
assert path == ["A", "B"]
|
||||
|
||||
# --- multi-hop path where one edge is against the query direction ---
|
||||
|
||||
def test_bfs_directed_false_multihop(self):
|
||||
"""A→B, C→B graph: directed=False lets us find A→B→C (i.e. A→C via B)."""
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "B")
|
||||
g.add_edge("C", "B") # oriented towards B, not away from it
|
||||
# undirected view: A-B-C, so A→C path exists
|
||||
path = self.finder.bfs_shortest_path(g, "A", "C", directed=False)
|
||||
assert path[0] == "A" and path[-1] == "C"
|
||||
assert "B" in path
|
||||
|
||||
def test_dijkstra_directed_false_multihop(self):
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "B")
|
||||
g.add_edge("C", "B")
|
||||
path = self.finder.dijkstra_shortest_path(g, "A", "C", directed=False)
|
||||
assert path[0] == "A" and path[-1] == "C"
|
||||
assert "B" in path
|
||||
|
||||
# --- PathResponse.directed field ---
|
||||
|
||||
def test_path_response_directed_field_exists(self):
|
||||
"""PathResponse must carry a directed field."""
|
||||
from semantica.explorer.schemas import PathResponse
|
||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"], directed=False)
|
||||
assert r.directed is False
|
||||
|
||||
def test_path_response_directed_field_defaults_true(self):
|
||||
from semantica.explorer.schemas import PathResponse
|
||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"])
|
||||
assert r.directed is True
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from pydantic import BaseModel
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
|
||||
class TestDeepSeekProviderInit(unittest.TestCase):
|
||||
"""Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
|
||||
|
||||
def setUp(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
self.DeepSeekProvider = DeepSeekProvider
|
||||
|
||||
def test_base_url_set_on_init(self):
|
||||
"""self.base_url must be set before _init_client is called (PR #482 regression)."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
||||
self.assertTrue(
|
||||
hasattr(provider, "base_url"),
|
||||
"DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
|
||||
)
|
||||
self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
|
||||
|
||||
def test_base_url_points_to_v1_endpoint(self):
|
||||
"""base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
||||
self.assertIn("/v1", provider.base_url, "base_url must include /v1")
|
||||
|
||||
def test_init_client_uses_openai_not_deepseek(self):
|
||||
"""_init_client must import openai.OpenAI, not deepseek.Client."""
|
||||
mock_openai_cls = MagicMock()
|
||||
mock_openai_instance = MagicMock()
|
||||
mock_openai_cls.return_value = mock_openai_instance
|
||||
|
||||
with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
|
||||
# Re-import to pick up patched sys.modules
|
||||
import importlib
|
||||
import semantica.semantic_extract.providers as providers_mod
|
||||
importlib.reload(providers_mod)
|
||||
DeepSeekProvider = providers_mod.DeepSeekProvider
|
||||
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
|
||||
mock_openai_cls.assert_called_once_with(
|
||||
api_key="sk-test",
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
)
|
||||
self.assertIs(provider.client, mock_openai_instance)
|
||||
|
||||
def test_init_client_no_api_key_leaves_client_none(self):
|
||||
"""Without an API key, client must remain None."""
|
||||
with patch("semantica.semantic_extract.providers.config") as mock_cfg:
|
||||
mock_cfg.get_api_key.return_value = None
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key=None)
|
||||
provider.client = None # simulate _init_client no-op
|
||||
self.assertFalse(provider.is_available())
|
||||
|
||||
def test_init_client_handles_openai_import_error(self):
|
||||
"""If openai is not installed, _init_client must set client=None, not raise."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None # manually simulate ImportError path
|
||||
# Directly call _init_client with openai blocked
|
||||
with patch.dict("sys.modules", {"openai": None}):
|
||||
try:
|
||||
provider._init_client()
|
||||
except Exception as e:
|
||||
self.fail(f"_init_client raised unexpectedly: {e}")
|
||||
self.assertIsNone(provider.client)
|
||||
|
||||
def test_is_available_true_when_client_set(self):
|
||||
"""is_available() returns True when self.client is an OpenAI instance."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = MagicMock()
|
||||
self.assertTrue(provider.is_available())
|
||||
|
||||
def test_is_available_false_when_client_none(self):
|
||||
"""is_available() returns False when self.client is None."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
self.assertFalse(provider.is_available())
|
||||
|
||||
def test_no_deepseek_module_imported(self):
|
||||
"""deepseek module must NOT be imported by _init_client after PR #482."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
blocked = MagicMock()
|
||||
blocked.__spec__ = None
|
||||
with patch.dict("sys.modules", {"deepseek": None}):
|
||||
# _init_client should succeed even if deepseek is completely absent
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.OpenAI.return_value = MagicMock()
|
||||
with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
|
||||
try:
|
||||
provider._init_client()
|
||||
except Exception as e:
|
||||
self.fail(f"_init_client raised when deepseek absent: {e}")
|
||||
|
||||
|
||||
class TestDeepSeekProviderGenerate(unittest.TestCase):
|
||||
"""Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
|
||||
|
||||
def _make_provider(self, api_key="sk-test"):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key=api_key)
|
||||
provider.client = MagicMock()
|
||||
return provider
|
||||
|
||||
def test_generate_uses_chat_completions(self):
|
||||
"""generate() must call client.chat.completions.create."""
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = "hello"
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
result = provider.generate("test prompt")
|
||||
|
||||
provider.client.chat.completions.create.assert_called_once()
|
||||
self.assertEqual(result, "hello")
|
||||
|
||||
def test_generate_passes_model(self):
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = "x"
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
provider.generate("p", model="deepseek-reasoner")
|
||||
kwargs = provider.client.chat.completions.create.call_args[1]
|
||||
self.assertEqual(kwargs["model"], "deepseek-reasoner")
|
||||
|
||||
def test_generate_structured_returns_parsed_json(self):
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = '{"key": "value"}'
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
result = provider.generate_structured("test prompt")
|
||||
self.assertEqual(result, {"key": "value"})
|
||||
|
||||
def test_generate_raises_without_client(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
provider.generate("prompt")
|
||||
|
||||
def test_generate_structured_raises_without_client(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
provider.generate_structured("prompt")
|
||||
|
||||
|
||||
class TestDeepSeekInstructorPath(unittest.TestCase):
|
||||
"""Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
|
||||
|
||||
def _make_provider(self, api_key="sk-test"):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
from unittest.mock import MagicMock
|
||||
from openai import OpenAI
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key=api_key)
|
||||
# After PR #482, client is an OpenAI instance
|
||||
mock_client = MagicMock(spec=OpenAI)
|
||||
provider.client = mock_client
|
||||
return provider
|
||||
|
||||
def test_generate_typed_instructor_openai_isinstance_check(self):
|
||||
"""After PR #482, client is OpenAI, so instructor path must use from_openai."""
|
||||
from openai import OpenAI
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = MagicMock(spec=OpenAI)
|
||||
|
||||
self.assertIsInstance(
|
||||
provider.client, OpenAI,
|
||||
"client must be OpenAI instance for instructor isinstance check to pass",
|
||||
)
|
||||
|
||||
|
||||
class TestVerboseModeAssignment(unittest.TestCase):
|
||||
"""Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
|
||||
|
||||
def _make_openai_provider(self):
|
||||
from semantica.semantic_extract.providers import OpenAIProvider
|
||||
with patch.object(OpenAIProvider, "_init_client", return_value=None):
|
||||
provider = OpenAIProvider(api_key="sk-test")
|
||||
provider.client = MagicMock()
|
||||
return provider
|
||||
|
||||
def test_generate_typed_no_verbose_no_name_error(self):
|
||||
"""generate_typed must not raise NameError for verbose_mode when verbose not passed."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_instructor = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.return_value = Schema(value="ok")
|
||||
mock_instructor.from_openai.return_value = mock_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
try:
|
||||
result = provider.generate_typed("prompt", Schema)
|
||||
except NameError as e:
|
||||
self.fail(f"NameError for verbose_mode: {e}")
|
||||
except Exception:
|
||||
pass # other errors are OK — we only care NameError is gone
|
||||
|
||||
def test_generate_typed_verbose_true_prints(self):
|
||||
"""When verbose=True, generate_typed must print the confirmation line."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema, verbose=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output = captured.getvalue()
|
||||
# verbose_mode=True should trigger the print statement
|
||||
self.assertIn("generate_typed", output)
|
||||
|
||||
def test_generate_typed_verbose_false_no_print(self):
|
||||
"""When verbose=False (default), generate_typed must not print anything."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertEqual(captured.getvalue(), "")
|
||||
|
||||
def test_generate_typed_verbose_from_config(self):
|
||||
"""verbose_mode must also respect config-level verbose setting."""
|
||||
provider = self._make_openai_provider()
|
||||
provider.config["verbose"] = True
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertIn("generate_typed", captured.getvalue())
|
||||
|
||||
|
||||
class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
|
||||
"""Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
|
||||
|
||||
def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
|
||||
"""generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
from openai import OpenAI
|
||||
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
mock_openai_client = MagicMock(spec=OpenAI)
|
||||
provider.client = mock_openai_client
|
||||
|
||||
class Schema(BaseModel):
|
||||
label: str
|
||||
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("no from_provider")
|
||||
mock_instructor.Mode.JSON = "json"
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
result = provider.generate_typed("classify this", Schema)
|
||||
|
||||
# Must have called from_openai with the existing client (not a fresh one)
|
||||
mock_instructor.from_openai.assert_called_once_with(
|
||||
mock_openai_client, mode="json"
|
||||
)
|
||||
self.assertEqual(result.label, "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -283,5 +283,166 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user