mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72afe6217 |
@@ -1 +0,0 @@
|
|||||||
# Initialization
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Intialization
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Initialization
|
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v5
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: ./site
|
path: ./site
|
||||||
|
|
||||||
|
|||||||
@@ -111,12 +111,5 @@ sample_data/
|
|||||||
# Test Results
|
# Test Results
|
||||||
test_results.txt
|
test_results.txt
|
||||||
|
|
||||||
# Frontend workspace artifacts
|
|
||||||
semantica-explorer/
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||||
semantica/static/
|
semantica/static/
|
||||||
|
|
||||||
# Local graph explorer test datasets
|
|
||||||
demo_out/
|
|
||||||
|
|||||||
@@ -7,57 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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`.
|
- **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):
|
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:25-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app/semantica-explorer
|
WORKDIR /app/semantica-explorer
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
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.
|
- ✅ **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.
|
- ✅ **Quality & Deduplication** — conflict detection, entity resolution, and pipeline validation built in.
|
||||||
|
|
||||||
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install semantica
|
pip install semantica
|
||||||
@@ -170,75 +170,6 @@ Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an
|
|||||||
|
|
||||||
</table>
|
</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)
|
### Plugin Bundles (Claude Code · Cursor · Codex)
|
||||||
|
|
||||||
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
|
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
|
||||||
@@ -944,6 +875,53 @@ if result.valid:
|
|||||||
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
|
- **`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/`)
|
- **[`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
|
### Graph Databases
|
||||||
- **Neo4j** — Cypher queries via `semantica.graph_store`
|
- **Neo4j** — Cypher queries via `semantica.graph_store`
|
||||||
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
|
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
|
||||||
@@ -974,6 +952,67 @@ if result.valid:
|
|||||||
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
||||||
- **Ollama** — local models including remote server support
|
- **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
|
## 🛠️ Installation
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# Semantica v0.3.0 — Release Notes
|
||||||
|
|
||||||
|
**Released:** 2026-03-10
|
||||||
|
**PyPI:** `pip install semantica`
|
||||||
|
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
||||||
|
**Classification:** Production/Stable
|
||||||
|
|
||||||
|
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
| Contributor | Role |
|
||||||
|
|------------|------|
|
||||||
|
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
||||||
|
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
||||||
|
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
||||||
|
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
||||||
|
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.0 — Stable (2026-03-10)
|
||||||
|
|
||||||
|
### Context Graph Feature Completeness
|
||||||
|
|
||||||
|
**Temporal Validity Windows** (by @KaifAhmad1)
|
||||||
|
|
||||||
|
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
||||||
|
|
||||||
|
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
||||||
|
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
||||||
|
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
||||||
|
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
||||||
|
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
||||||
|
|
||||||
|
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
||||||
|
|
||||||
|
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
||||||
|
|
||||||
|
**Cross-Graph Navigation** (by @KaifAhmad1)
|
||||||
|
|
||||||
|
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
||||||
|
|
||||||
|
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
||||||
|
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
||||||
|
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
||||||
|
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
||||||
|
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
||||||
|
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
||||||
|
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
||||||
|
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
||||||
|
|
||||||
|
**Other Fixes** (by @KaifAhmad1)
|
||||||
|
|
||||||
|
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
||||||
|
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
||||||
|
|
||||||
|
**Test Coverage Added**
|
||||||
|
|
||||||
|
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
||||||
|
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.0-beta — Beta (2026-03-07)
|
||||||
|
|
||||||
|
### Semantic Extraction Fixes
|
||||||
|
|
||||||
|
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
||||||
|
|
||||||
|
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
||||||
|
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
||||||
|
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
||||||
|
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
||||||
|
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
||||||
|
|
||||||
|
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
||||||
|
|
||||||
|
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
||||||
|
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
||||||
|
|
||||||
|
### Incremental / Delta Processing
|
||||||
|
|
||||||
|
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
||||||
|
|
||||||
|
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
||||||
|
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
||||||
|
- Version snapshot management with graph URI tracking and metadata storage.
|
||||||
|
- `prune_versions()` for automatic snapshot retention cleanup.
|
||||||
|
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
||||||
|
|
||||||
|
### Deduplication v2
|
||||||
|
|
||||||
|
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
||||||
|
|
||||||
|
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
||||||
|
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
||||||
|
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
||||||
|
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
||||||
|
|
||||||
|
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
||||||
|
|
||||||
|
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
||||||
|
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Deduplication & Conflict Resolution Strategies Summary
|
||||||
|
|
||||||
|
## Quick Reference by Use Case
|
||||||
|
|
||||||
|
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
||||||
|
|----------|---------------------|----------------|-------------------|---------------------|
|
||||||
|
| **Finance** |
|
||||||
|
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||||
|
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
||||||
|
| **Biomedical** |
|
||||||
|
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
||||||
|
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||||
|
| **Cybersecurity** |
|
||||||
|
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
||||||
|
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
||||||
|
| **Blockchain** |
|
||||||
|
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
||||||
|
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
||||||
|
| **Intelligence** |
|
||||||
|
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
||||||
|
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
||||||
|
| **Renewable Energy** |
|
||||||
|
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
||||||
|
| **Supply Chain** |
|
||||||
|
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategy Rationale by Domain
|
||||||
|
|
||||||
|
### Finance
|
||||||
|
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
||||||
|
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
||||||
|
|
||||||
|
### Biomedical
|
||||||
|
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
||||||
|
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
||||||
|
|
||||||
|
### Cybersecurity
|
||||||
|
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
||||||
|
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
||||||
|
|
||||||
|
### Blockchain
|
||||||
|
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
||||||
|
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
||||||
|
|
||||||
|
### Intelligence
|
||||||
|
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
||||||
|
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
||||||
|
|
||||||
|
### Renewable Energy
|
||||||
|
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
||||||
|
|
||||||
|
### Supply Chain
|
||||||
|
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method Distribution
|
||||||
|
|
||||||
|
### Deduplication Methods (9 total)
|
||||||
|
- `pairwise`: 2 notebooks (real-time processing)
|
||||||
|
- `batch`: 3 notebooks (large datasets)
|
||||||
|
- `incremental`: 2 notebooks (streaming/continuous)
|
||||||
|
- `group`: 2 notebooks (related entities)
|
||||||
|
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
||||||
|
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
||||||
|
- `exact` (EntityResolver): 1 notebook (exact matching)
|
||||||
|
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
||||||
|
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
||||||
|
|
||||||
|
### Merge Strategies (5 total)
|
||||||
|
- `keep_first`: 1 notebook (first detection priority)
|
||||||
|
- `keep_last`: 1 notebook (latest information)
|
||||||
|
- `keep_most_complete`: 5 notebooks (preserve all details)
|
||||||
|
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
||||||
|
- `merge_all`: 3 notebooks (combine all information)
|
||||||
|
|
||||||
|
### Conflict Detection Methods (6 total)
|
||||||
|
- `value`: 4 notebooks (property value conflicts)
|
||||||
|
- `type`: 2 notebooks (type/classification conflicts)
|
||||||
|
- `entity`: 2 notebooks (entity-wide conflicts)
|
||||||
|
- `relationship`: 3 notebooks (relationship conflicts)
|
||||||
|
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
||||||
|
- `logical`: 2 notebooks (logical inconsistencies)
|
||||||
|
|
||||||
|
### Conflict Resolution Strategies (6 total)
|
||||||
|
- `voting`: 5 notebooks (majority vote)
|
||||||
|
- `credibility_weighted`: 4 notebooks (source credibility)
|
||||||
|
- `most_recent`: 3 notebooks (latest data)
|
||||||
|
- `first_seen`: 1 notebook (first detection)
|
||||||
|
- `highest_confidence`: 2 notebooks (most confident)
|
||||||
|
- `expert_review`: 1 notebook (manual review)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
||||||
|
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
||||||
|
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
||||||
|
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
||||||
|
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
||||||
|
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
||||||
|
|
||||||
Generated
+315
-857
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,7 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
@@ -44,7 +43,6 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
|
|||||||
+3
-28
@@ -15,7 +15,7 @@ const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/Enti
|
|||||||
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||||
|
|
||||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||||
type ExploreView = 'graph' | 'vocabulary';
|
type ExploreView = 'graph' | 'vocabulary';
|
||||||
type AnalyzeView = 'sparql' | 'reasoning';
|
type AnalyzeView = 'sparql' | 'reasoning';
|
||||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||||
@@ -311,39 +311,14 @@ function WorkspaceFallback() {
|
|||||||
return <div className="workspace-loading">Loading workspace…</div>;
|
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() {
|
export default function App() {
|
||||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
||||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||||
|
|
||||||
const renderWorkspace = () => {
|
const renderWorkspace = () => {
|
||||||
if (activeWorkspace === 'welcome') {
|
|
||||||
return <WelcomeScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeWorkspace === 'explore') {
|
if (activeWorkspace === 'explore') {
|
||||||
return (
|
return (
|
||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
@@ -476,7 +451,7 @@ export default function App() {
|
|||||||
<style>{shellStyles}</style>
|
<style>{shellStyles}</style>
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<aside className="app-rail">
|
<aside className="app-rail">
|
||||||
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div>
|
||||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -42,10 +42,6 @@ export interface NodeAttributes {
|
|||||||
haloColor?: string;
|
haloColor?: string;
|
||||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
communityId?: string;
|
|
||||||
isCommunityGroup?: boolean;
|
|
||||||
memberCount?: number;
|
|
||||||
anchorNodeId?: string | null;
|
|
||||||
|
|
||||||
nodeType: string;
|
nodeType: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -78,12 +74,6 @@ export interface EdgeAttributes {
|
|||||||
parallelIndex?: number;
|
parallelIndex?: number;
|
||||||
parallelCount?: number;
|
parallelCount?: number;
|
||||||
familySize?: number;
|
familySize?: number;
|
||||||
rawEdgeIds?: string[];
|
|
||||||
isAggregated?: boolean;
|
|
||||||
aggregateCount?: number;
|
|
||||||
dominantEdgeType?: string;
|
|
||||||
representativeWeight?: number;
|
|
||||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
|
||||||
|
|
||||||
|
|
||||||
edgeType: string;
|
edgeType: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import type { CSSProperties } from "react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { graph } from "../../store/graphStore";
|
import { graph } from "../../store/graphStore";
|
||||||
import { GRAPH_THEME } from "./graphTheme";
|
import { GRAPH_THEME } from "./graphTheme";
|
||||||
import type { GraphSelectedNodeKind } from "./types";
|
|
||||||
|
|
||||||
export type LinkPrediction = {
|
export type LinkPrediction = {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -15,16 +14,10 @@ export type PathResponse = {
|
|||||||
path: string[];
|
path: string[];
|
||||||
edge_ids?: string[];
|
edge_ids?: string[];
|
||||||
total_weight: number;
|
total_weight: number;
|
||||||
hop_count: number;
|
|
||||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface GraphInspectorPanelProps {
|
export interface GraphInspectorPanelProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
inspectableNodeId?: string | null;
|
|
||||||
selectedNodeKind?: GraphSelectedNodeKind;
|
|
||||||
canActivateFocused?: boolean;
|
|
||||||
focusedUnavailableReason?: string | null;
|
|
||||||
predictions: LinkPrediction[];
|
predictions: LinkPrediction[];
|
||||||
predictionType: string;
|
predictionType: string;
|
||||||
onPredictionTypeChange: (value: string) => void;
|
onPredictionTypeChange: (value: string) => void;
|
||||||
@@ -153,10 +146,6 @@ function PathFlowViz({
|
|||||||
|
|
||||||
export function GraphInspectorPanel({
|
export function GraphInspectorPanel({
|
||||||
nodeId,
|
nodeId,
|
||||||
inspectableNodeId,
|
|
||||||
selectedNodeKind = "none",
|
|
||||||
canActivateFocused = false,
|
|
||||||
focusedUnavailableReason = null,
|
|
||||||
predictions,
|
predictions,
|
||||||
predictionType,
|
predictionType,
|
||||||
onPredictionTypeChange,
|
onPredictionTypeChange,
|
||||||
@@ -182,38 +171,7 @@ export function GraphInspectorPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
|
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||||
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;
|
color?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -236,26 +194,12 @@ export function GraphInspectorPanel({
|
|||||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||||
{String(attributes?.label ?? effectiveNodeId)}
|
{String(attributes?.label ?? nodeId)}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||||
{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 }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||||
{attributes?.valid_from || attributes?.valid_until ? (
|
{attributes?.valid_from || attributes?.valid_until ? (
|
||||||
<span style={subtleChipStyle}>temporal</span>
|
<span style={subtleChipStyle}>temporal</span>
|
||||||
@@ -280,7 +224,7 @@ export function GraphInspectorPanel({
|
|||||||
<button
|
<button
|
||||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||||
onClick={onRunPredictions}
|
onClick={onRunPredictions}
|
||||||
disabled={isRunningPredictions || !actionNodeId}
|
disabled={isRunningPredictions}
|
||||||
>
|
>
|
||||||
{isRunningPredictions ? (
|
{isRunningPredictions ? (
|
||||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||||
@@ -288,10 +232,10 @@ export function GraphInspectorPanel({
|
|||||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||||
Provenance JSON
|
Provenance JSON
|
||||||
</button>
|
</button>
|
||||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||||
Provenance MD
|
Provenance MD
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +257,7 @@ export function GraphInspectorPanel({
|
|||||||
placeholder="Target node ID"
|
placeholder="Target node ID"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||||
|
|
||||||
{pathResult?.path?.length ? (
|
{pathResult?.path?.length ? (
|
||||||
<PathFlowViz
|
<PathFlowViz
|
||||||
@@ -432,14 +376,6 @@ const inputStyle: CSSProperties = {
|
|||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
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 = {
|
const actionButtonStyle: CSSProperties = {
|
||||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
|
|||||||
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||||
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
||||||
import { createGraphLoadProgress } from "./graphLoading";
|
import { createGraphLoadProgress } from "./graphLoading";
|
||||||
import { resolveDisplayGraph } from "./graphSceneState";
|
|
||||||
import {
|
import {
|
||||||
chooseColorAccessor,
|
chooseColorAccessor,
|
||||||
colorForNodeKey,
|
colorForNodeKey,
|
||||||
@@ -42,7 +41,6 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
|||||||
lensMode: "neighborhood",
|
lensMode: "neighborhood",
|
||||||
effectQuality: "bounded",
|
effectQuality: "bounded",
|
||||||
};
|
};
|
||||||
const EMPTY_PATH: string[] = [];
|
|
||||||
|
|
||||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||||
|
|
||||||
@@ -69,10 +67,6 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
|||||||
valid_until: attributes.valid_until ?? null,
|
valid_until: attributes.valid_until ?? null,
|
||||||
properties: attributes.properties ?? {},
|
properties: attributes.properties ?? {},
|
||||||
neighborCount: graph.neighbors(nodeId).length,
|
neighborCount: graph.neighbors(nodeId).length,
|
||||||
visibleNeighborCount: graph.neighbors(nodeId).length,
|
|
||||||
collapsedNeighborCount: 0,
|
|
||||||
isNeighborhoodCollapsed: false,
|
|
||||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +113,6 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
|||||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||||
const [graphVersion, setGraphVersion] = useState(0);
|
const [graphVersion, setGraphVersion] = useState(0);
|
||||||
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
|
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]);
|
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
|
||||||
|
|
||||||
@@ -457,16 +447,9 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
|||||||
<SigmaSceneAdapter
|
<SigmaSceneAdapter
|
||||||
ref={sceneRef}
|
ref={sceneRef}
|
||||||
onNodeSelect={onNodeSelect}
|
onNodeSelect={onNodeSelect}
|
||||||
graphVersion={graphVersion}
|
|
||||||
graphReady={Boolean(snapshot)}
|
|
||||||
displayGraph={displayResult.graph}
|
|
||||||
displayMeta={displayResult.meta}
|
|
||||||
displayState={displayResult.state}
|
|
||||||
selectedEdgeId=""
|
selectedEdgeId=""
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
|
||||||
activePath={activePath}
|
activePath={activePath}
|
||||||
activePathEdgeIds={EMPTY_PATH}
|
|
||||||
effectsState={STAGE_EFFECTS_STATE}
|
effectsState={STAGE_EFFECTS_STATE}
|
||||||
isLayoutRunning={isLayoutRunning}
|
isLayoutRunning={isLayoutRunning}
|
||||||
onLayoutRunningChange={onLayoutRunningChange}
|
onLayoutRunningChange={onLayoutRunningChange}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type Graph from "graphology";
|
|
||||||
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
|
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
|
||||||
import { logEvent } from "../../store/registryStore";
|
import { logEvent } from "../../store/registryStore";
|
||||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||||
@@ -12,7 +11,6 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
|||||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||||
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
|
|
||||||
import {
|
import {
|
||||||
type GraphPlugin,
|
type GraphPlugin,
|
||||||
type GraphPluginActionRequest,
|
type GraphPluginActionRequest,
|
||||||
@@ -25,7 +23,6 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
|||||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||||
import type {
|
import type {
|
||||||
GraphAnalyticsSnapshot,
|
GraphAnalyticsSnapshot,
|
||||||
GraphDisplayStateSnapshot,
|
|
||||||
GraphDiagnosticsSnapshot,
|
GraphDiagnosticsSnapshot,
|
||||||
GraphEffectToggle,
|
GraphEffectToggle,
|
||||||
GraphEffectsState,
|
GraphEffectsState,
|
||||||
@@ -33,7 +30,6 @@ import type {
|
|||||||
GraphLoadProgress,
|
GraphLoadProgress,
|
||||||
GraphLoadSummary,
|
GraphLoadSummary,
|
||||||
GraphSelectedEdgeState,
|
GraphSelectedEdgeState,
|
||||||
GraphSelectedNodeKind,
|
|
||||||
GraphSelectedNodeState,
|
GraphSelectedNodeState,
|
||||||
GraphTemporalState,
|
GraphTemporalState,
|
||||||
GraphViewMode,
|
GraphViewMode,
|
||||||
@@ -93,7 +89,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
|
|||||||
pathFlowEnabled: false,
|
pathFlowEnabled: false,
|
||||||
lensEnabled: false,
|
lensEnabled: false,
|
||||||
temporalEmphasisEnabled: false,
|
temporalEmphasisEnabled: false,
|
||||||
semanticRegionsEnabled: false,
|
semanticRegionsEnabled: true,
|
||||||
contoursEnabled: false,
|
contoursEnabled: false,
|
||||||
pathfindingEnabled: false,
|
pathfindingEnabled: false,
|
||||||
communitiesEnabled: false,
|
communitiesEnabled: false,
|
||||||
@@ -109,15 +105,6 @@ const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then(
|
|||||||
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
|
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
|
||||||
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
|
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
|
||||||
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
|
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 {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
@@ -507,10 +494,7 @@ function buildRealtimeEdgeAttributes(payload: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSelectedNodeState(
|
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||||
nodeId: string,
|
|
||||||
displayState: GraphDisplayStateSnapshot,
|
|
||||||
): GraphSelectedNodeState | null {
|
|
||||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -535,64 +519,31 @@ function buildSelectedNodeState(
|
|||||||
valid_until: attributes.valid_until ?? null,
|
valid_until: attributes.valid_until ?? null,
|
||||||
properties: attributes.properties ?? {},
|
properties: attributes.properties ?? {},
|
||||||
neighborCount: graph.neighbors(nodeId).length,
|
neighborCount: graph.neighbors(nodeId).length,
|
||||||
visibleNeighborCount: displayState.selectedVisibleNeighborIds.length,
|
|
||||||
collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length,
|
|
||||||
isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0,
|
|
||||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type FocusResolution = {
|
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||||
kind: GraphSelectedNodeKind;
|
if (!edgeId || !graph.hasEdge(edgeId)) {
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
|
const [sourceId, targetId] = graph.extremities(edgeId);
|
||||||
const attributes = displayGraph.getEdgeAttributes(edgeId) as {
|
const attributes = graph.getEdgeAttributes(edgeId) as {
|
||||||
edgeType?: string;
|
edgeType?: string;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
familyId?: string;
|
familyId?: string;
|
||||||
rawEdgeIds?: string[];
|
|
||||||
isAggregated?: boolean;
|
|
||||||
aggregateCount?: number;
|
|
||||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
|
||||||
dominantEdgeType?: string;
|
|
||||||
representativeWeight?: number;
|
|
||||||
};
|
};
|
||||||
const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId];
|
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
|
||||||
const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0];
|
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
|
||||||
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 properties = attributes.properties ?? {};
|
||||||
const familyId = String(attributes.familyId || edgeId);
|
const familyId = String(attributes.familyId || edgeId);
|
||||||
let familySize = 0;
|
let familySize = 0;
|
||||||
let siblingCount = 0;
|
let siblingCount = 0;
|
||||||
rawEdgeIds.forEach((rawEdgeId) => {
|
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
|
||||||
if (!graph.hasEdge(rawEdgeId)) {
|
const edgeAttrs = candidateAttrs as { familyId?: string };
|
||||||
return;
|
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
|
||||||
}
|
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
|
||||||
const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string };
|
|
||||||
const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId);
|
|
||||||
if (String(candidateAttrs.familyId || rawEdgeId) === familyId) {
|
|
||||||
familySize += 1;
|
familySize += 1;
|
||||||
}
|
}
|
||||||
if (candidateSource === sourceId && candidateTarget === targetId) {
|
if (candidateSource === sourceId && candidateTarget === targetId) {
|
||||||
@@ -600,11 +551,6 @@ function buildSelectedEdgeState(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (attributes.isAggregated) {
|
|
||||||
siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
|
|
||||||
familySize = Math.max(familySize, siblingCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
familyId,
|
familyId,
|
||||||
@@ -618,12 +564,6 @@ function buildSelectedEdgeState(
|
|||||||
provenanceCount: getProvenanceCount(properties),
|
provenanceCount: getProvenanceCount(properties),
|
||||||
familySize,
|
familySize,
|
||||||
siblingCount,
|
siblingCount,
|
||||||
isAggregated: Boolean(attributes.isAggregated),
|
|
||||||
aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length),
|
|
||||||
rawEdgeIds,
|
|
||||||
bundleKind: attributes.bundleKind ?? null,
|
|
||||||
dominantEdgeType: attributes.dominantEdgeType ?? null,
|
|
||||||
representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,15 +624,9 @@ function collectPluginOverlays(
|
|||||||
|
|
||||||
export function GraphWorkspace() {
|
export function GraphWorkspace() {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
|
||||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
||||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||||
const [graphReady, setGraphReady] = useState(false);
|
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
|
||||||
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
const [searchError, setSearchError] = useState("");
|
const [searchError, setSearchError] = useState("");
|
||||||
@@ -729,24 +663,15 @@ export function GraphWorkspace() {
|
|||||||
focusedNodeId: "",
|
focusedNodeId: "",
|
||||||
activePath: [],
|
activePath: [],
|
||||||
activePathEdgeIds: [],
|
activePathEdgeIds: [],
|
||||||
viewMode: "full",
|
viewMode: "focused",
|
||||||
zoomTier: "overview",
|
zoomTier: "overview",
|
||||||
isLayoutRunning: false,
|
isLayoutRunning: false,
|
||||||
});
|
});
|
||||||
const reload = useReloadGraph();
|
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({
|
const { data: summary, isLoading, isFetching } = useLoadGraph({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
onGraphReady: (graphSummary) => {
|
onGraphReady: (graphSummary) => {
|
||||||
setGraphReady(true);
|
|
||||||
setGraphVersion((current) => current + 1);
|
|
||||||
setIsLayoutRunning(!graphSummary.layoutReady);
|
setIsLayoutRunning(!graphSummary.layoutReady);
|
||||||
if (settlingOverlayTimeoutRef.current !== null) {
|
if (settlingOverlayTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(settlingOverlayTimeoutRef.current);
|
window.clearTimeout(settlingOverlayTimeoutRef.current);
|
||||||
@@ -775,7 +700,7 @@ export function GraphWorkspace() {
|
|||||||
settlingOverlayTimeoutRef.current = null;
|
settlingOverlayTimeoutRef.current = null;
|
||||||
}, 900);
|
}, 900);
|
||||||
},
|
},
|
||||||
onProgress: handleLoadProgress,
|
onProgress: setLoadingProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -837,7 +762,6 @@ export function GraphWorkspace() {
|
|||||||
});
|
});
|
||||||
prevActiveIdsRef.current = nextActiveIds;
|
prevActiveIdsRef.current = nextActiveIds;
|
||||||
setActiveNodeCount(data.active_node_count);
|
setActiveNodeCount(data.active_node_count);
|
||||||
setGraphVersion((current) => current + 1);
|
|
||||||
sceneRef.current?.getRuntime()?.requestRender();
|
sceneRef.current?.getRuntime()?.requestRender();
|
||||||
});
|
});
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
@@ -853,166 +777,16 @@ export function GraphWorkspace() {
|
|||||||
};
|
};
|
||||||
}, [debouncedTime, isLoading]);
|
}, [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) => {
|
const focusNode = useCallback((nodeId: string) => {
|
||||||
if (!nodeId) {
|
setSelectedNodeId(nodeId);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
|
||||||
const nextSelectedNodeId = nodeId;
|
|
||||||
|
|
||||||
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
|
|
||||||
setLastGroupedSelectedNodeId(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedNodeId(nextSelectedNodeId);
|
|
||||||
setSelectedEdgeId("");
|
setSelectedEdgeId("");
|
||||||
setPathResult(null);
|
setPathResult(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setSearchError("");
|
setSearchError("");
|
||||||
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
|
if (nodeId) {
|
||||||
setFocusedNodeId(nextSelectedNodeId);
|
|
||||||
setIsLayoutRunning(false);
|
setIsLayoutRunning(false);
|
||||||
}
|
}
|
||||||
}, [viewMode]);
|
}, []);
|
||||||
|
|
||||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||||
setSelectedEdgeId(edgeId);
|
setSelectedEdgeId(edgeId);
|
||||||
@@ -1041,14 +815,14 @@ export function GraphWorkspace() {
|
|||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
const handleRunPredictions = useCallback(async () => {
|
const handleRunPredictions = useCallback(async () => {
|
||||||
if (!inspectableNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
setIsRunningPredictions(true);
|
setIsRunningPredictions(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/enrich/links", {
|
const response = await fetch("/api/enrich/links", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
node_id: inspectableNodeId,
|
node_id: selectedNodeId,
|
||||||
top_n: 6,
|
top_n: 6,
|
||||||
candidate_type: predictionType || undefined,
|
candidate_type: predictionType || undefined,
|
||||||
min_score: 0,
|
min_score: 0,
|
||||||
@@ -1065,13 +839,13 @@ export function GraphWorkspace() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsRunningPredictions(false);
|
setIsRunningPredictions(false);
|
||||||
}
|
}
|
||||||
}, [inspectableNodeId, predictionType]);
|
}, [predictionType, selectedNodeId]);
|
||||||
|
|
||||||
const handleTracePath = useCallback(async () => {
|
const handleTracePath = useCallback(async () => {
|
||||||
if (!inspectableNodeId || !pathTargetId.trim()) return;
|
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||||
@@ -1088,12 +862,12 @@ export function GraphWorkspace() {
|
|||||||
console.error("[GraphWorkspace] path trace failed", pathError);
|
console.error("[GraphWorkspace] path trace failed", pathError);
|
||||||
setPathResult(null);
|
setPathResult(null);
|
||||||
}
|
}
|
||||||
}, [inspectableNodeId, pathTargetId]);
|
}, [pathTargetId, selectedNodeId]);
|
||||||
|
|
||||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||||
if (!inspectableNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
const suffix = format === "markdown" ? "markdown" : "json";
|
const suffix = format === "markdown" ? "markdown" : "json";
|
||||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
|
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -1101,12 +875,12 @@ export function GraphWorkspace() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
document.body.removeChild(anchor);
|
document.body.removeChild(anchor);
|
||||||
}, [inspectableNodeId]);
|
}, [selectedNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -1131,7 +905,6 @@ export function GraphWorkspace() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
|
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();
|
sceneRef.current?.getRuntime()?.requestRender();
|
||||||
}
|
}
|
||||||
if (eventType === "ADD_EDGE") {
|
if (eventType === "ADD_EDGE") {
|
||||||
@@ -1145,7 +918,6 @@ export function GraphWorkspace() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id} → ${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
|
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();
|
sceneRef.current?.getRuntime()?.requestRender();
|
||||||
}
|
}
|
||||||
} catch (socketError) {
|
} catch (socketError) {
|
||||||
@@ -1158,163 +930,32 @@ export function GraphWorkspace() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCollapsedNeighborhoodNodeIds([]);
|
|
||||||
setFocusedNodeId("");
|
|
||||||
setLastGroupedSelectedNodeId("");
|
|
||||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
|
||||||
|
|
||||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
|
||||||
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
|
||||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
|
||||||
const activePath = pathResult?.path ?? EMPTY_PATH;
|
|
||||||
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
|
|
||||||
const structuralSelectedNodeId = useMemo(() => {
|
|
||||||
if (viewMode === "focused") {
|
|
||||||
return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
|
|
||||||
}
|
|
||||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
|
|
||||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
|
||||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
|
||||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
|
||||||
const displayResult = useMemo(
|
|
||||||
() => (
|
|
||||||
viewMode === "grouped"
|
|
||||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
}))
|
|
||||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
[
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
groupedDisplayCandidate,
|
|
||||||
structuralActivePath,
|
|
||||||
structuralActivePathEdgeIds,
|
|
||||||
structuralSelectedNodeId,
|
|
||||||
viewMode,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const displayState = useMemo(
|
|
||||||
() => (
|
|
||||||
viewMode === "grouped"
|
|
||||||
? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, {
|
|
||||||
groupedViewAvailable,
|
|
||||||
groupedViewReason,
|
|
||||||
selectedNodeKind: focusedSelectionResolution.kind,
|
|
||||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
|
||||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
|
||||||
})
|
|
||||||
: resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
groupedViewAvailable,
|
|
||||||
groupedViewReason,
|
|
||||||
selectedNodeKind: focusedSelectionResolution.kind,
|
|
||||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
|
||||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
[
|
|
||||||
activePath,
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
displayResult.graph,
|
|
||||||
focusedSelectionResolution.kind,
|
|
||||||
focusedSelectionResolution.reason,
|
|
||||||
focusedSelectionResolution.resolvedNodeId,
|
|
||||||
groupedViewAvailable,
|
|
||||||
groupedViewReason,
|
|
||||||
selectedNodeId,
|
|
||||||
viewMode,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const displayMeta = displayResult.meta;
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode === "grouped" && !groupedViewAvailable) {
|
|
||||||
debugGraphWorkspace("grouped-view-reset-to-full", {
|
|
||||||
reason: groupedViewReason,
|
|
||||||
graphVersion,
|
|
||||||
});
|
|
||||||
setViewMode("full");
|
|
||||||
setFocusedNodeId("");
|
|
||||||
setSelectedNodeId((currentSelectedNodeId) => (
|
|
||||||
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) {
|
|
||||||
setViewMode("full");
|
|
||||||
setFocusedNodeId("");
|
|
||||||
}
|
|
||||||
}, [focusedNodeId, graphVersion, viewMode]);
|
|
||||||
const previousDisplayGraphRef = useRef(displayResult.graph);
|
|
||||||
const previousDisplayStateRef = useRef(displayState);
|
|
||||||
useEffect(() => {
|
|
||||||
const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph;
|
|
||||||
const displayStateChanged = previousDisplayStateRef.current !== displayState;
|
|
||||||
debugGraphWorkspace("display-state-derived", {
|
|
||||||
selectedNodeId,
|
|
||||||
structuralSelectedNodeId,
|
|
||||||
viewMode,
|
|
||||||
graphRebuilt,
|
|
||||||
displayStateChanged,
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)),
|
|
||||||
});
|
|
||||||
previousDisplayGraphRef.current = displayResult.graph;
|
|
||||||
previousDisplayStateRef.current = displayState;
|
|
||||||
}, [
|
|
||||||
aggregationEnabled,
|
|
||||||
collapsedNeighborhoodNodeIds,
|
|
||||||
displayResult.graph,
|
|
||||||
displayState,
|
|
||||||
selectedNodeId,
|
|
||||||
structuralSelectedNodeId,
|
|
||||||
viewMode,
|
|
||||||
]);
|
|
||||||
const focusedSummary = useMemo(() => {
|
const focusedSummary = useMemo(() => {
|
||||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localNeighborCount = graph.neighbors(selectedNodeId).length;
|
const localNeighborCount = graph.neighbors(selectedNodeId).length;
|
||||||
if (viewMode === "focused") {
|
if (viewMode === "focused") {
|
||||||
const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
|
const visibleNeighbors = Math.min(localNeighborCount, 16);
|
||||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === "grouped") {
|
|
||||||
return "Grouped structure view with direct community drill-in";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (displayState.selectedCollapsedNeighborIds.length > 0) {
|
|
||||||
return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${localNeighborCount} direct neighbors highlighted`;
|
return `${localNeighborCount} direct neighbors highlighted`;
|
||||||
}, [displayState, selectedNodeId, viewMode]);
|
}, [selectedNodeId, viewMode]);
|
||||||
|
|
||||||
|
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
|
||||||
|
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||||
|
const activePath = pathResult?.path ?? [];
|
||||||
|
const activePathEdgeIds = pathResult?.edge_ids ?? [];
|
||||||
const graphSummary = summary as GraphLoadSummary | null;
|
const graphSummary = summary as GraphLoadSummary | null;
|
||||||
const selectedNodeState = useMemo(
|
const selectedNodeState = useMemo(
|
||||||
() => buildSelectedNodeState(selectedNodeId, displayState),
|
() => buildSelectedNodeState(selectedNodeId),
|
||||||
[displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||||
);
|
);
|
||||||
const selectedEdgeState = useMemo(
|
const selectedEdgeState = useMemo(
|
||||||
() => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
|
() => buildSelectedEdgeState(selectedEdgeId),
|
||||||
[displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||||
);
|
);
|
||||||
const temporalState = useMemo(
|
const temporalState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1420,21 +1061,7 @@ export function GraphWorkspace() {
|
|||||||
focusNode(action.nodeId);
|
focusNode(action.nodeId);
|
||||||
return;
|
return;
|
||||||
case "setViewMode":
|
case "setViewMode":
|
||||||
requestViewMode(action.viewMode);
|
setViewMode(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;
|
return;
|
||||||
case "toggleEffect":
|
case "toggleEffect":
|
||||||
setEffectToggle(action.effect, (current) => !current);
|
setEffectToggle(action.effect, (current) => !current);
|
||||||
@@ -1472,7 +1099,7 @@ export function GraphWorkspace() {
|
|||||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
|
}, [focusNode, setEffectToggle]);
|
||||||
|
|
||||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||||
@@ -1512,11 +1139,9 @@ export function GraphWorkspace() {
|
|||||||
getEffectsState: () => effectsState,
|
getEffectsState: () => effectsState,
|
||||||
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
|
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
|
||||||
getAnalyticsSnapshot: () => graphAnalyticsState,
|
getAnalyticsSnapshot: () => graphAnalyticsState,
|
||||||
getDisplayState: () => displayState,
|
|
||||||
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
|
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
|
||||||
dispatchAction: handlePluginAction,
|
dispatchAction: handlePluginAction,
|
||||||
}), [
|
}), [
|
||||||
displayState,
|
|
||||||
graphAnalyticsState,
|
graphAnalyticsState,
|
||||||
diagnosticsSnapshot,
|
diagnosticsSnapshot,
|
||||||
effectsState,
|
effectsState,
|
||||||
@@ -1642,58 +1267,23 @@ export function GraphWorkspace() {
|
|||||||
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
|
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
|
||||||
const groups: GraphToolbarGroup[] = [];
|
const groups: GraphToolbarGroup[] = [];
|
||||||
|
|
||||||
if (hasGraphContent) {
|
if (selectedNodeId) {
|
||||||
groups.push({
|
groups.push({
|
||||||
id: "view-mode",
|
id: "view-mode",
|
||||||
items: [
|
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",
|
id: "view-full",
|
||||||
label: "Full Graph",
|
label: "Full Graph",
|
||||||
title: "Return to the full graph context",
|
title: "Return to the full graph context",
|
||||||
active: viewMode === "full",
|
active: viewMode === "full",
|
||||||
onClick: () => requestViewMode("full"),
|
onClick: () => setViewMode("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" }),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1714,13 +1304,25 @@ export function GraphWorkspace() {
|
|||||||
id: "zoom-in",
|
id: "zoom-in",
|
||||||
label: "+ Zoom In",
|
label: "+ Zoom In",
|
||||||
title: "Zoom in (or scroll up on the canvas)",
|
title: "Zoom in (or scroll up on the canvas)",
|
||||||
onClick: () => sceneRef.current?.zoomIn(),
|
onClick: () => {
|
||||||
|
const runtime = sceneRef.current?.getRuntime();
|
||||||
|
if (runtime?.renderer === "sigma") {
|
||||||
|
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||||
|
camera.animatedZoom({ duration: 200 });
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "zoom-out",
|
id: "zoom-out",
|
||||||
label: "- Zoom Out",
|
label: "- Zoom Out",
|
||||||
title: "Zoom out (or scroll down on the canvas)",
|
title: "Zoom out (or scroll down on the canvas)",
|
||||||
onClick: () => sceneRef.current?.zoomOut(),
|
onClick: () => {
|
||||||
|
const runtime = sceneRef.current?.getRuntime();
|
||||||
|
if (runtime?.renderer === "sigma") {
|
||||||
|
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||||
|
camera.animatedUnzoom({ duration: 200 });
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fit-view",
|
id: "fit-view",
|
||||||
@@ -1760,42 +1362,18 @@ export function GraphWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
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 = {
|
const sceneAdapterProps = {
|
||||||
onNodeSelect: focusNode,
|
onNodeSelect: focusNode,
|
||||||
onEdgeSelect: handleEdgeSelect,
|
onEdgeSelect: handleEdgeSelect,
|
||||||
graphVersion,
|
|
||||||
graphReady,
|
|
||||||
displayGraph: displayResult.graph,
|
|
||||||
displayMeta,
|
|
||||||
displayState,
|
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
focusedNodeId,
|
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
activePath,
|
activePath,
|
||||||
activePathEdgeIds,
|
activePathEdgeIds,
|
||||||
effectsState,
|
effectsState,
|
||||||
temporalState,
|
temporalState,
|
||||||
isLayoutRunning,
|
isLayoutRunning,
|
||||||
layoutSource: graphSummary?.layoutSource,
|
|
||||||
viewMode,
|
viewMode,
|
||||||
showFitViewButton: false,
|
showFitViewButton: false,
|
||||||
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
|
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
|
||||||
@@ -1817,7 +1395,7 @@ export function GraphWorkspace() {
|
|||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
<div className="explore-toolbar">
|
<div className="explore-toolbar">
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
|
{showLoadingOverlay && loadingProgress ? (
|
||||||
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
|
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
|
||||||
) : null}
|
) : null}
|
||||||
{summary ? (
|
{summary ? (
|
||||||
@@ -1926,20 +1504,8 @@ export function GraphWorkspace() {
|
|||||||
|
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
|
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
|
||||||
{selectedEdgeState.isAggregated ? (
|
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
|
||||||
<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>
|
<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 ? (
|
{selectedEdgeState.provenanceCount > 0 ? (
|
||||||
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
|
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2042,10 +1608,6 @@ export function GraphWorkspace() {
|
|||||||
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
||||||
<LazyGraphInspectorPanel
|
<LazyGraphInspectorPanel
|
||||||
nodeId={selectedNodeId}
|
nodeId={selectedNodeId}
|
||||||
inspectableNodeId={inspectableNodeId || null}
|
|
||||||
selectedNodeKind={displayState.selectedNodeKind}
|
|
||||||
canActivateFocused={canActivateFocusedMode}
|
|
||||||
focusedUnavailableReason={displayState.focusedUnavailableReason}
|
|
||||||
predictions={predictions}
|
predictions={predictions}
|
||||||
predictionType={predictionType}
|
predictionType={predictionType}
|
||||||
onPredictionTypeChange={setPredictionType}
|
onPredictionTypeChange={setPredictionType}
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ type LinkPrediction = {
|
|||||||
type PathResponse = {
|
type PathResponse = {
|
||||||
path: GraphPath;
|
path: GraphPath;
|
||||||
total_weight: number;
|
total_weight: number;
|
||||||
hop_count: number;
|
|
||||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TemporalBounds = {
|
type TemporalBounds = {
|
||||||
@@ -139,10 +137,6 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
|
|||||||
valid_until: node.valid_until ?? null,
|
valid_until: node.valid_until ?? null,
|
||||||
properties: node.properties ?? {},
|
properties: node.properties ?? {},
|
||||||
neighborCount,
|
neighborCount,
|
||||||
visibleNeighborCount: neighborCount,
|
|
||||||
collapsedNeighborCount: 0,
|
|
||||||
isNeighborhoodCollapsed: false,
|
|
||||||
canCollapseNeighborhood: neighborCount > 8,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,10 +425,6 @@ export function GraphWorkspaceShell() {
|
|||||||
valid_until: null,
|
valid_until: null,
|
||||||
properties: searchNode.properties ?? {},
|
properties: searchNode.properties ?? {},
|
||||||
neighborCount: 0,
|
neighborCount: 0,
|
||||||
visibleNeighborCount: 0,
|
|
||||||
collapsedNeighborCount: 0,
|
|
||||||
isNeighborhoodCollapsed: false,
|
|
||||||
canCollapseNeighborhood: false,
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||||
@@ -563,19 +553,6 @@ export function GraphWorkspaceShell() {
|
|||||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||||
}, [viewMode, visibleSelectedNode]);
|
}, [viewMode, visibleSelectedNode]);
|
||||||
|
|
||||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
|
||||||
if (nextViewMode === "focused") {
|
|
||||||
if (!selectedNodeId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setViewMode("focused");
|
|
||||||
setIsLayoutRunning(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setViewMode("full");
|
|
||||||
}, [selectedNodeId]);
|
|
||||||
|
|
||||||
const showLoadingOverlay =
|
const showLoadingOverlay =
|
||||||
isLoading
|
isLoading
|
||||||
|| isFetching
|
|| isFetching
|
||||||
@@ -652,8 +629,8 @@ export function GraphWorkspaceShell() {
|
|||||||
<div className="graph-toggle-cluster">
|
<div className="graph-toggle-cluster">
|
||||||
{selectedNodeId ? (
|
{selectedNodeId ? (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
<button onClick={() => 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={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
<button onClick={() => setViewMode("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>
|
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
fitView: () => canvasRef.current?.fitView(),
|
fitView: () => canvasRef.current?.fitView(),
|
||||||
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
|
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
|
||||||
zoomIn: () => canvasRef.current?.zoomIn(),
|
|
||||||
zoomOut: () => canvasRef.current?.zoomOut(),
|
|
||||||
getRuntime: () => runtimeRef.current,
|
getRuntime: () => runtimeRef.current,
|
||||||
setLayoutRunning: onLayoutRunningChange
|
setLayoutRunning: onLayoutRunningChange
|
||||||
? (running: boolean) => {
|
? (running: boolean) => {
|
||||||
|
|||||||
@@ -5,21 +5,11 @@ export const focusCameraBehavior: GraphBehavior = {
|
|||||||
attach: () => {},
|
attach: () => {},
|
||||||
detach: () => {},
|
detach: () => {},
|
||||||
performAction: (context, action) => {
|
performAction: (context, action) => {
|
||||||
if (action.type === "focusNode") {
|
if (action.type !== "focusNode") {
|
||||||
context.focusNodeInView(action.nodeId);
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === "centerSelection") {
|
context.focusNodeInView(action.nodeId);
|
||||||
context.centerSelectionInView(action.nodeId);
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "centerGroupedSelection") {
|
|
||||||
context.centerGroupedSelectionInView(action.nodeId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,23 @@
|
|||||||
import type { GraphBehavior } from "./types";
|
import type { GraphBehavior } from "./types";
|
||||||
|
|
||||||
export function createSearchFocusBehavior(): GraphBehavior {
|
export function createSearchFocusBehavior(): GraphBehavior {
|
||||||
let lastSelectedNodeId = "";
|
let lastFocusedNodeId = "";
|
||||||
let lastViewMode = "";
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "search-focus",
|
id: "search-focus",
|
||||||
attach: () => {},
|
attach: () => {},
|
||||||
detach: () => {
|
detach: () => {
|
||||||
lastSelectedNodeId = "";
|
lastFocusedNodeId = "";
|
||||||
lastViewMode = "";
|
|
||||||
},
|
},
|
||||||
onStateChange: (context, interactionState) => {
|
onStateChange: (context, interactionState) => {
|
||||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||||
const nextViewMode = interactionState.viewMode;
|
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||||
if (nextViewMode !== lastViewMode) {
|
lastFocusedNodeId = nextFocusedNodeId;
|
||||||
lastViewMode = nextViewMode;
|
|
||||||
lastSelectedNodeId = nextSelectedNodeId;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
|
||||||
lastSelectedNodeId = nextSelectedNodeId;
|
|
||||||
lastViewMode = nextViewMode;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSelectedNodeId = nextSelectedNodeId;
|
lastFocusedNodeId = nextFocusedNodeId;
|
||||||
lastViewMode = nextViewMode;
|
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||||
context.dispatchAction({
|
|
||||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
|
||||||
nodeId: nextSelectedNodeId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
|||||||
|
|
||||||
export type GraphBehaviorActionRequest =
|
export type GraphBehaviorActionRequest =
|
||||||
| { type: "fitView" }
|
| { type: "fitView" }
|
||||||
| { type: "focusNode"; nodeId: string }
|
| { type: "focusNode"; nodeId: string };
|
||||||
| { type: "centerSelection"; nodeId: string }
|
|
||||||
| { type: "centerGroupedSelection"; nodeId: string };
|
|
||||||
|
|
||||||
export interface GraphBehaviorContext {
|
export interface GraphBehaviorContext {
|
||||||
sigma: Sigma;
|
sigma: Sigma;
|
||||||
@@ -19,8 +17,6 @@ export interface GraphBehaviorContext {
|
|||||||
onNodeSelectionChange: (nodeId: string) => void;
|
onNodeSelectionChange: (nodeId: string) => void;
|
||||||
onEdgeSelectionChange: (edgeId: string) => void;
|
onEdgeSelectionChange: (edgeId: string) => void;
|
||||||
focusNodeInView: (nodeId: string) => void;
|
focusNodeInView: (nodeId: string) => void;
|
||||||
centerSelectionInView: (nodeId: string) => void;
|
|
||||||
centerGroupedSelectionInView: (nodeId: string) => void;
|
|
||||||
fitCurrentView: () => void;
|
fitCurrentView: () => void;
|
||||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { GraphBehavior } from "./types";
|
import type { GraphBehavior } from "./types";
|
||||||
import type { GraphViewMode } from "../types";
|
|
||||||
|
|
||||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||||
let lastViewMode: GraphViewMode | null = null;
|
let lastViewMode: "focused" | "full" | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "view-mode-switch",
|
id: "view-mode-switch",
|
||||||
@@ -16,10 +15,9 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastViewMode = interactionState.viewMode;
|
lastViewMode = interactionState.viewMode;
|
||||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
|
||||||
|
|
||||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
if (interactionState.focusedNodeId) {
|
||||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
|
|||||||
|
|
||||||
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
||||||
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "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 {
|
export interface GraphTheme {
|
||||||
palette: {
|
palette: {
|
||||||
@@ -191,35 +190,6 @@ export interface GraphTheme {
|
|||||||
motion: {
|
motion: {
|
||||||
cameraMs: number;
|
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: {
|
effects: {
|
||||||
pathPulse: {
|
pathPulse: {
|
||||||
minZoomTier: GraphZoomTier;
|
minZoomTier: GraphZoomTier;
|
||||||
@@ -335,10 +305,10 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
zoomTiers: {
|
zoomTiers: {
|
||||||
overview: {
|
overview: {
|
||||||
maxRatio: Number.POSITIVE_INFINITY,
|
maxRatio: Number.POSITIVE_INFINITY,
|
||||||
nodeScale: 0.72,
|
nodeScale: 0.88,
|
||||||
labelThreshold: 0.995,
|
labelThreshold: 0.92,
|
||||||
labelBudget: 4,
|
labelBudget: 28,
|
||||||
edgePriorityThreshold: 0.72,
|
edgePriorityThreshold: 0.55,
|
||||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||||
edgeSizeScale: 0.62,
|
edgeSizeScale: 0.62,
|
||||||
showBadges: false,
|
showBadges: false,
|
||||||
@@ -347,21 +317,21 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
structure: {
|
structure: {
|
||||||
maxRatio: 1.2,
|
maxRatio: 1.2,
|
||||||
nodeScale: 0.94,
|
nodeScale: 1.02,
|
||||||
labelThreshold: 0.93,
|
labelThreshold: 0.82,
|
||||||
labelBudget: 18,
|
labelBudget: 60,
|
||||||
edgePriorityThreshold: 0.4,
|
edgePriorityThreshold: 0.3,
|
||||||
arrowPriorityThreshold: 0.75,
|
arrowPriorityThreshold: 0.65,
|
||||||
edgeSizeScale: 0.92,
|
edgeSizeScale: 1.05,
|
||||||
showBadges: false,
|
showBadges: true,
|
||||||
showCurves: false,
|
showCurves: true,
|
||||||
showContextualArrows: false,
|
showContextualArrows: true,
|
||||||
},
|
},
|
||||||
inspection: {
|
inspection: {
|
||||||
maxRatio: 0.5,
|
maxRatio: 0.5,
|
||||||
nodeScale: 1,
|
nodeScale: 1.08,
|
||||||
labelThreshold: 0.8,
|
labelThreshold: 0.6,
|
||||||
labelBudget: 40,
|
labelBudget: 120,
|
||||||
edgePriorityThreshold: 0,
|
edgePriorityThreshold: 0,
|
||||||
arrowPriorityThreshold: 0.45,
|
arrowPriorityThreshold: 0.45,
|
||||||
edgeSizeScale: 1.18,
|
edgeSizeScale: 1.18,
|
||||||
@@ -371,7 +341,7 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
forceVisibleStates: ["hovered", "selected", "path"],
|
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||||
policies: {
|
policies: {
|
||||||
none: { minZoomTier: "inspection" },
|
none: { minZoomTier: "inspection" },
|
||||||
priority: { minZoomTier: "overview" },
|
priority: { minZoomTier: "overview" },
|
||||||
@@ -421,26 +391,26 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
backgroundScale: 0.52,
|
backgroundScale: 0.52,
|
||||||
mutedAlpha: 0.16,
|
mutedAlpha: 0.08,
|
||||||
strokeHierarchy: {
|
strokeHierarchy: {
|
||||||
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
||||||
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
||||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
|
||||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
|
||||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
|
||||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
|
||||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
|
||||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", 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: "inspection" },
|
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: "inspection" },
|
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||||
},
|
},
|
||||||
selectedRing: {
|
selectedRing: {
|
||||||
@@ -503,35 +473,6 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
motion: {
|
motion: {
|
||||||
cameraMs: 380,
|
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: {
|
effects: {
|
||||||
pathPulse: {
|
pathPulse: {
|
||||||
minZoomTier: "structure",
|
minZoomTier: "structure",
|
||||||
@@ -589,7 +530,7 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
maxGroups: 8,
|
maxGroups: 8,
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
enabledInDev: IS_DEV,
|
enabledInDev: import.meta.env.DEV,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selected = context.getSelectedNodeState();
|
const selected = context.getSelectedNodeState();
|
||||||
const displayState = context.getDisplayState();
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return {
|
return {
|
||||||
id: NEIGHBORHOOD_PANEL_ID,
|
id: NEIGHBORHOOD_PANEL_ID,
|
||||||
@@ -83,11 +82,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
|||||||
return left.label.localeCompare(right.label);
|
return left.label.localeCompare(right.label);
|
||||||
})
|
})
|
||||||
.slice(0, MAX_NEIGHBORS);
|
.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 {
|
return {
|
||||||
id: NEIGHBORHOOD_PANEL_ID,
|
id: NEIGHBORHOOD_PANEL_ID,
|
||||||
@@ -103,34 +97,6 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
|||||||
<div style={summaryStyle}>
|
<div style={summaryStyle}>
|
||||||
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
||||||
</div>
|
</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 ? (
|
{neighbors.length ? (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{neighbors.map((neighbor) => (
|
{neighbors.map((neighbor) => (
|
||||||
@@ -193,16 +159,6 @@ const neighborButtonStyle: CSSProperties = {
|
|||||||
cursor: "pointer",
|
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 = {
|
const swatchStyle: CSSProperties = {
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { GraphTheme } from "../graphTheme";
|
|||||||
import type { GraphSceneRuntime } from "../scene";
|
import type { GraphSceneRuntime } from "../scene";
|
||||||
import type {
|
import type {
|
||||||
GraphAnalyticsSnapshot,
|
GraphAnalyticsSnapshot,
|
||||||
GraphDisplayStateSnapshot,
|
|
||||||
GraphDiagnosticsSnapshot,
|
GraphDiagnosticsSnapshot,
|
||||||
GraphEffectsState,
|
GraphEffectsState,
|
||||||
GraphEffectToggle,
|
GraphEffectToggle,
|
||||||
@@ -32,8 +31,6 @@ export type GraphPluginActionRequest =
|
|||||||
| { type: "focusNode"; nodeId: string }
|
| { type: "focusNode"; nodeId: string }
|
||||||
| { type: "selectNode"; nodeId: string }
|
| { type: "selectNode"; nodeId: string }
|
||||||
| { type: "setViewMode"; viewMode: GraphViewMode }
|
| { type: "setViewMode"; viewMode: GraphViewMode }
|
||||||
| { type: "collapseNeighborhood" }
|
|
||||||
| { type: "expandNeighborhood" }
|
|
||||||
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
||||||
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
||||||
| { type: "togglePanel"; panelId: string }
|
| { type: "togglePanel"; panelId: string }
|
||||||
@@ -80,7 +77,6 @@ export interface GraphPluginContext {
|
|||||||
getEffectsState: () => GraphEffectsState;
|
getEffectsState: () => GraphEffectsState;
|
||||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
|
||||||
isPanelOpen: (panelId: string) => boolean;
|
isPanelOpen: (panelId: string) => boolean;
|
||||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
|||||||
import type {
|
import type {
|
||||||
GraphAnalyticsSnapshot,
|
GraphAnalyticsSnapshot,
|
||||||
GraphCameraState,
|
GraphCameraState,
|
||||||
GraphDisplayMeta,
|
|
||||||
GraphDisplayStateSnapshot,
|
|
||||||
GraphDiagnosticsSnapshot,
|
GraphDiagnosticsSnapshot,
|
||||||
GraphEffectsState,
|
GraphEffectsState,
|
||||||
GraphInteractionState,
|
GraphInteractionState,
|
||||||
@@ -24,8 +22,6 @@ export interface GraphSceneRuntime {
|
|||||||
scene: unknown;
|
scene: unknown;
|
||||||
graph: GraphSceneGraph;
|
graph: GraphSceneGraph;
|
||||||
displayGraph: GraphSceneGraph;
|
displayGraph: GraphSceneGraph;
|
||||||
graphVersion: number;
|
|
||||||
layoutMode?: GraphDisplayMeta["layoutMode"];
|
|
||||||
requestRender: () => void;
|
requestRender: () => void;
|
||||||
getCameraState: () => GraphCameraState | null;
|
getCameraState: () => GraphCameraState | null;
|
||||||
}
|
}
|
||||||
@@ -41,13 +37,7 @@ export interface GraphSceneEventMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphSceneProps extends GraphSceneEventMap {
|
export interface GraphSceneProps extends GraphSceneEventMap {
|
||||||
graphVersion: number;
|
|
||||||
graphReady: boolean;
|
|
||||||
displayGraph: GraphSceneGraph;
|
|
||||||
displayMeta: GraphDisplayMeta;
|
|
||||||
displayState?: GraphDisplayStateSnapshot;
|
|
||||||
selectedNodeId: string;
|
selectedNodeId: string;
|
||||||
focusedNodeId: string;
|
|
||||||
selectedEdgeId: string;
|
selectedEdgeId: string;
|
||||||
activePath?: string[];
|
activePath?: string[];
|
||||||
activePathEdgeIds?: string[];
|
activePathEdgeIds?: string[];
|
||||||
@@ -66,8 +56,6 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
|||||||
export interface GraphSceneHandle {
|
export interface GraphSceneHandle {
|
||||||
fitView: () => void;
|
fitView: () => void;
|
||||||
focusNode: (nodeId: string) => void;
|
focusNode: (nodeId: string) => void;
|
||||||
zoomIn: () => void;
|
|
||||||
zoomOut: () => void;
|
|
||||||
getRuntime: () => GraphSceneRuntime | null;
|
getRuntime: () => GraphSceneRuntime | null;
|
||||||
setLayoutRunning?: (running: boolean) => void;
|
setLayoutRunning?: (running: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
export type GraphViewMode = "focused" | "full";
|
||||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||||
export type GraphLoadPhase =
|
export type GraphLoadPhase =
|
||||||
@@ -12,7 +12,6 @@ export type GraphLoadPhase =
|
|||||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||||
export type GraphEdgeInteractionState = "default" | "backbone" | "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 {
|
export interface GraphCameraState {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -32,28 +31,6 @@ export interface GraphInteractionState {
|
|||||||
isLayoutRunning: boolean;
|
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 =
|
export type GraphEffectToggle =
|
||||||
| "pathPulseEnabled"
|
| "pathPulseEnabled"
|
||||||
| "pathFlowEnabled"
|
| "pathFlowEnabled"
|
||||||
@@ -268,10 +245,6 @@ export interface GraphSelectedNodeState {
|
|||||||
valid_until?: string | null;
|
valid_until?: string | null;
|
||||||
properties: Record<string, unknown>;
|
properties: Record<string, unknown>;
|
||||||
neighborCount: number;
|
neighborCount: number;
|
||||||
visibleNeighborCount: number;
|
|
||||||
collapsedNeighborCount: number;
|
|
||||||
isNeighborhoodCollapsed: boolean;
|
|
||||||
canCollapseNeighborhood: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphSelectedEdgeState {
|
export interface GraphSelectedEdgeState {
|
||||||
@@ -287,12 +260,6 @@ export interface GraphSelectedEdgeState {
|
|||||||
provenanceCount: number;
|
provenanceCount: number;
|
||||||
familySize: number;
|
familySize: number;
|
||||||
siblingCount: number;
|
siblingCount: number;
|
||||||
isAggregated: boolean;
|
|
||||||
aggregateCount: number;
|
|
||||||
rawEdgeIds: string[];
|
|
||||||
bundleKind: "parallel" | "bidirectional" | "community" | null;
|
|
||||||
dominantEdgeType: string | null;
|
|
||||||
representativeWeight: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphStageHandle {
|
export interface GraphStageHandle {
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import {
|
|
||||||
batchMergeEdges,
|
|
||||||
batchMergeNodes,
|
|
||||||
clearGraph,
|
|
||||||
} from "../src/store/graphStore.ts";
|
|
||||||
import {
|
|
||||||
checkGroupedViewAvailability,
|
|
||||||
resolveDisplayGraph,
|
|
||||||
resolveGroupedDisplayNodeId,
|
|
||||||
resolveGroupedDisplayStateSnapshot,
|
|
||||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
|
||||||
|
|
||||||
function addNode(id: string, semanticGroup = "entity") {
|
|
||||||
batchMergeNodes([
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
attributes: {
|
|
||||||
label: id,
|
|
||||||
content: id,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
size: 8,
|
|
||||||
color: "#63E6FF",
|
|
||||||
baseColor: "#63E6FF",
|
|
||||||
nodeType: semanticGroup,
|
|
||||||
semanticGroup,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
|
||||||
batchMergeEdges([
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
attributes: {
|
|
||||||
edgeType: "related_to",
|
|
||||||
weight,
|
|
||||||
properties: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.beforeEach(() => {
|
|
||||||
clearGraph();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
clearGraph();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
|
||||||
addNode("a");
|
|
||||||
addNode("b");
|
|
||||||
addEdge("e1", "a", "b", 1);
|
|
||||||
addEdge("e2", "a", "b", 2);
|
|
||||||
|
|
||||||
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
|
|
||||||
assert.equal(graph.size, 1);
|
|
||||||
|
|
||||||
const edgeId = graph.edges()[0];
|
|
||||||
const attrs = graph.getEdgeAttributes(edgeId) as {
|
|
||||||
isAggregated?: boolean;
|
|
||||||
aggregateCount?: number;
|
|
||||||
rawEdgeIds?: string[];
|
|
||||||
bundleKind?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(attrs.isAggregated, true);
|
|
||||||
assert.equal(attrs.aggregateCount, 2);
|
|
||||||
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
|
|
||||||
assert.equal(attrs.bundleKind, "parallel");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
|
|
||||||
addNode("center");
|
|
||||||
for (let index = 0; index < 10; index += 1) {
|
|
||||||
const neighbor = `n${index}`;
|
|
||||||
addNode(neighbor);
|
|
||||||
addEdge(`edge-${index}`, "center", neighbor, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
|
|
||||||
aggregationEnabled: false,
|
|
||||||
collapsedNeighborhoodNodeIds: ["center"],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(state.selectedRootNodeId, "center");
|
|
||||||
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
|
|
||||||
assert.equal(state.selectedVisibleNeighborIds.length, 9);
|
|
||||||
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
|
||||||
const left = ["a1", "a2", "a3", "a4"];
|
|
||||||
const right = ["b1", "b2", "b3", "b4"];
|
|
||||||
|
|
||||||
[...left, ...right].forEach((nodeId, index) => {
|
|
||||||
addNode(nodeId, index < left.length ? "left" : "right");
|
|
||||||
});
|
|
||||||
|
|
||||||
let edgeIndex = 0;
|
|
||||||
for (let i = 0; i < left.length; i += 1) {
|
|
||||||
for (let j = 0; j < left.length; j += 1) {
|
|
||||||
if (i !== j) {
|
|
||||||
addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < right.length; i += 1) {
|
|
||||||
for (let j = 0; j < right.length; j += 1) {
|
|
||||||
if (i !== j) {
|
|
||||||
addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addEdge("bridge-1", "a1", "b1", 0.1);
|
|
||||||
addEdge("bridge-2", "a2", "b2", 0.1);
|
|
||||||
|
|
||||||
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
|
||||||
|
|
||||||
assert.equal(state.groupedViewAvailable, true);
|
|
||||||
|
|
||||||
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
|
|
||||||
assert.ok(communityNodes.length >= 2);
|
|
||||||
|
|
||||||
const hasCommunityEdge = graph
|
|
||||||
.edges()
|
|
||||||
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
|
|
||||||
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
|
|
||||||
|
|
||||||
assert.equal(hasCommunityEdge, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
|
|
||||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
|
||||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
|
|
||||||
addNode("x");
|
|
||||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
|
||||||
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
|
|
||||||
const left = ["a1", "a2", "a3", "a4"];
|
|
||||||
const right = ["b1", "b2", "b3", "b4"];
|
|
||||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
|
||||||
|
|
||||||
let edgeIndex = 0;
|
|
||||||
for (let i = 0; i < left.length; i += 1) {
|
|
||||||
for (let j = 0; j < left.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < right.length; i += 1) {
|
|
||||||
for (let j = 0; j < right.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addEdge("bridge-1", "a1", "b1", 0.1);
|
|
||||||
|
|
||||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
|
||||||
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
|
|
||||||
assert.ok(communityNodes.length >= 2, "expected community nodes");
|
|
||||||
|
|
||||||
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
|
|
||||||
assert.ok(resolved !== null, "should resolve a1 to a community node");
|
|
||||||
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
|
|
||||||
|
|
||||||
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
|
|
||||||
addNode("p");
|
|
||||||
addNode("q");
|
|
||||||
addEdge("e1", "p", "q");
|
|
||||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
|
||||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
|
|
||||||
groupedViewAvailable: true,
|
|
||||||
groupedViewReason: null,
|
|
||||||
});
|
|
||||||
assert.equal(state.selectedNodeKind, "none");
|
|
||||||
assert.equal(state.selectedRootNodeId, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
|
|
||||||
const left = ["c1", "c2", "c3", "c4"];
|
|
||||||
const right = ["d1", "d2", "d3", "d4"];
|
|
||||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
|
||||||
|
|
||||||
let edgeIndex = 0;
|
|
||||||
for (let i = 0; i < left.length; i += 1) {
|
|
||||||
for (let j = 0; j < left.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < right.length; i += 1) {
|
|
||||||
for (let j = 0; j < right.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addEdge("bridge-c1", "c1", "d1", 0.1);
|
|
||||||
addEdge("bridge-c2", "c2", "d2", 0.1);
|
|
||||||
|
|
||||||
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
|
||||||
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
|
|
||||||
groupedViewAvailable: true,
|
|
||||||
groupedViewReason: null,
|
|
||||||
selectedNodeKind: "grouped",
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
|
|
||||||
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
|
|
||||||
assert.equal(state.groupedViewAvailable, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
|
|
||||||
const result = checkGroupedViewAvailability();
|
|
||||||
assert.equal(result.available, false);
|
|
||||||
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkGroupedViewAvailability returns available when communities exist", () => {
|
|
||||||
const left = ["e1", "e2", "e3", "e4"];
|
|
||||||
const right = ["f1", "f2", "f3", "f4"];
|
|
||||||
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
|
||||||
|
|
||||||
let edgeIndex = 0;
|
|
||||||
for (let i = 0; i < left.length; i += 1) {
|
|
||||||
for (let j = 0; j < left.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = 0; i < right.length; i += 1) {
|
|
||||||
for (let j = 0; j < right.length; j += 1) {
|
|
||||||
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addEdge("bridge-e1", "e1", "f1", 0.1);
|
|
||||||
|
|
||||||
const result = checkGroupedViewAvailability();
|
|
||||||
assert.equal(result.available, true);
|
|
||||||
assert.equal(result.reason, null);
|
|
||||||
});
|
|
||||||
+158
-163
@@ -1,155 +1,158 @@
|
|||||||
# Semantica Plugins (Community Guide)
|
# Semantica Plugins (Community Guide)
|
||||||
|
|
||||||
> **v0.4.0** — 17 domain skills · 3 agents · 8 platform plugins · Knowledge Explorer UI
|
Semantica ships a shared plugin bundle under `plugins/` with skills, agents, and hooks for knowledge graphs, context graphs, decision intelligence, reasoning, explainability, provenance, ontology, and export workflows.
|
||||||
|
|
||||||
Semantica ships a shared plugin bundle under `plugins/` that works across every major AI coding assistant. Connect any supported platform to Semantica's knowledge graph engine for semantic extraction, decision intelligence, reasoning, provenance, ontology, and export workflows.
|
This README covers installation across every supported platform.
|
||||||
|
|
||||||
---
|
## Supported Platforms
|
||||||
|
|
||||||
## Platform Plugins
|
| Platform | Method | Config file |
|
||||||
|
|---|---|---|
|
||||||
Semantica provides a dedicated plugin for each platform. Every plugin shares the same `skills/`, `agents/`, and `hooks/` bundle — only the manifest format differs.
|
| Claude Code | Native plugin bundle | `plugins/.claude-plugin/plugin.json` |
|
||||||
|
| Cursor | Native plugin bundle | `plugins/.cursor-plugin/plugin.json` |
|
||||||
| # | Platform | Plugin Folder | Setup |
|
| Codex CLI | Native plugin bundle | `plugins/.codex-plugin/plugin.json` |
|
||||||
|---|----------|--------------|-------|
|
| Windsurf | MCP server + plugin bundle | `plugins/.windsurf-plugin/plugin.json` |
|
||||||
| 1 | **Claude Code** | `.claude-plugin/` | `claude --plugin-dir ./plugins` |
|
| Cline (VS Code) | MCP server + plugin bundle | `plugins/.cline-plugin/plugin.json` |
|
||||||
| 2 | **Cursor** | `.cursor-plugin/` | Cursor Marketplace → refresh |
|
| Continue | MCP server | `plugins/.continue-plugin/plugin.json` |
|
||||||
| 3 | **Codex** | `.codex-plugin/` | Marketplace UI → install |
|
| VS Code | MCP server | `plugins/.vscode-plugin/plugin.json` |
|
||||||
| 4 | **Cline** | `.cline-plugin/` | Cline MCP settings |
|
| Claude Desktop | MCP server | — (see MCP section below) |
|
||||||
| 5 | **Windsurf** | `.windsurf-plugin/` | `mcp_config.json` |
|
| Any MCP client | MCP server | `python -m semantica.mcp_server` |
|
||||||
| 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
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Hawksight-AI/semantica.git
|
git clone https://github.com/Hawksight-AI/semantica.git
|
||||||
cd semantica
|
cd semantica
|
||||||
pip install semantica # Python 3.10+
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
2. Ensure the plugin bundle exists at:
|
||||||
|
|
||||||
## Knowledge Explorer (v0.4.0)
|
```text
|
||||||
|
plugins/
|
||||||
Launch the interactive graph dashboard:
|
skills/ ← 17 domain skills
|
||||||
|
agents/ ← 3 specialized agents
|
||||||
```bash
|
hooks/ ← hooks.json
|
||||||
semantica-explorer --graph my_graph.json --port 8000
|
.claude-plugin/ ← Claude Code manifest
|
||||||
|
.cursor-plugin/ ← Cursor manifest
|
||||||
|
.codex-plugin/ ← Codex CLI manifest
|
||||||
|
.windsurf-plugin/← Windsurf manifest + MCP config
|
||||||
|
.cline-plugin/ ← Cline manifest + MCP config
|
||||||
|
.continue-plugin/← Continue manifest + MCP config
|
||||||
|
.vscode-plugin/ ← VS Code manifest + MCP config
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:5174** to explore:
|
## Plugin Contents
|
||||||
|
|
||||||
- **Graph** — interactive canvas with ForceAtlas2 layout, path highlight, community coloring
|
- `skills/`: 17 domain skills (`causal`, `decision`, `explain`, `reason`, `temporal`, etc.)
|
||||||
- **Decisions** — causal chains and outcome analysis
|
- `agents/`: specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
||||||
- **Reasoning** — run deductive / abductive rules
|
- `hooks/hooks.json`: plugin hook configuration
|
||||||
- **SPARQL** — Monaco editor for graph queries
|
- `.claude-plugin/plugin.json`: Claude manifest
|
||||||
- **Vocabulary** — ontology concept tree
|
- `.cursor-plugin/plugin.json`: Cursor manifest
|
||||||
- **Lineage** — provenance lineage diagram
|
- `.codex-plugin/plugin.json`: Codex manifest
|
||||||
- **Import / Export** — JSON, RDF, Parquet, GraphML
|
- `*/marketplace.json`: local marketplace definitions
|
||||||
|
|
||||||
---
|
## Install and Use in Claude Code
|
||||||
|
|
||||||
## Installation by Platform
|
### Local install (fastest)
|
||||||
|
|
||||||
### Claude Code
|
From the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude --plugin-dir ./plugins
|
claude --plugin-dir ./plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
Or inside a session:
|
If your Claude setup uses plugin commands in-session, use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add ./plugins
|
/plugin install ./plugins
|
||||||
/plugin install semantica@semantica-local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify:
|
### Install from a GitHub marketplace
|
||||||
|
|
||||||
|
Add a marketplace hosted in git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin marketplace add <owner>/semantica
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install Semantica from that marketplace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install semantica@<marketplace-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify in Claude
|
||||||
|
|
||||||
|
Run one of these in chat:
|
||||||
|
|
||||||
|
```text
|
||||||
/semantica:decision list
|
/semantica:decision list
|
||||||
/semantica:explain decision <decision_id>
|
/semantica:explain decision <decision_id>
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
If the plugin is installed correctly, Claude should recognize the `/semantica:*` skills.
|
||||||
|
|
||||||
### Cursor
|
## Install and Use in Codex
|
||||||
|
|
||||||
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`.
|
1. Ensure your repo marketplace exists at `.agents/plugins/marketplace.json`.
|
||||||
2. Set `source.path` to `./plugins` in the plugin entry.
|
2. Point the plugin entry `source.path` to `./plugins` (or your chosen plugin directory).
|
||||||
3. Restart Codex and install from the marketplace UI.
|
3. Restart Codex and install from the marketplace UI.
|
||||||
|
|
||||||
Verify:
|
Codex manifest used by this bundle:
|
||||||
|
|
||||||
```
|
- `.codex-plugin/plugin.json`
|
||||||
|
|
||||||
|
### Verify in Codex
|
||||||
|
|
||||||
|
After install, run a Semantica skill command in chat, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
/semantica:causal chain --subject <decision_id> --depth 3
|
/semantica:causal chain --subject <decision_id> --depth 3
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Install and Use in Cursor
|
||||||
|
|
||||||
### Cline
|
Cursor reads plugin metadata from:
|
||||||
|
|
||||||
In Cline MCP settings, add:
|
- `.cursor-plugin/plugin.json`
|
||||||
|
- `.cursor-plugin/marketplace.json`
|
||||||
|
|
||||||
```json
|
If you maintain a team/community plugin repo, publish this `plugins/` directory and refresh/reinstall in Cursor Marketplace to pick up updates.
|
||||||
{
|
|
||||||
"semantica": {
|
### Verify in Cursor
|
||||||
"command": "python",
|
|
||||||
"args": ["-m", "semantica.mcp_server"],
|
Try one of these commands:
|
||||||
"env": {}
|
|
||||||
}
|
```text
|
||||||
}
|
/semantica:reason deductive "IF Person(x) THEN Mortal(x)"
|
||||||
|
/semantica:visualize topology
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## First Commands to Try
|
||||||
|
|
||||||
### Windsurf
|
After installing on any platform, these are good smoke tests:
|
||||||
|
|
||||||
Add to `~/.codeium/windsurf/mcp_config.json`:
|
1. `/semantica:decision record <category> "<scenario>" "<reasoning>" <outcome> <confidence>`
|
||||||
|
2. `/semantica:decision list`
|
||||||
|
3. `/semantica:causal chain --subject <decision_id> --depth 3`
|
||||||
|
4. `/semantica:explain decision <decision_id>`
|
||||||
|
5. `/semantica:validate graph`
|
||||||
|
|
||||||
|
## MCP Server (Windsurf · Cline · Continue · VS Code · Claude Desktop · Any tool)
|
||||||
|
|
||||||
|
Semantica includes a full MCP server (`semantica/mcp_server.py`) that exposes 12 tools and 3 resources over stdio — compatible with any MCP-aware tool.
|
||||||
|
|
||||||
|
### Start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m semantica.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure in your tool
|
||||||
|
|
||||||
|
**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -162,11 +165,31 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**Windsurf** — `~/.codeium/windsurf/mcp_config.json`:
|
||||||
|
|
||||||
### Continue
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "semantica.mcp_server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Add to `~/.continue/config.json`:
|
**Cline** — Cline MCP settings panel → Add server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "semantica.mcp_server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Continue** — `~/.continue/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -180,50 +203,7 @@ Add to `~/.continue/config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All 17 Semantica skills appear in the `@semantica` context provider dropdown.
|
**VS Code** — `settings.json`:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -236,25 +216,40 @@ Or for the VS Code MCP extension:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Available MCP tools
|
||||||
|
|
||||||
## First Commands to Try
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `extract_entities` | Named entity recognition from text |
|
||||||
|
| `extract_relations` | Relation and triplet extraction from text |
|
||||||
|
| `record_decision` | Record a decision with full context and metadata |
|
||||||
|
| `query_decisions` | Query recorded decisions by natural language or category |
|
||||||
|
| `find_precedents` | Find past decisions similar to a scenario |
|
||||||
|
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
|
||||||
|
| `add_entity` | Add a node/entity to the knowledge graph |
|
||||||
|
| `add_relationship` | Add a directed edge between two entities |
|
||||||
|
| `run_reasoning` | Run IF/THEN rules over facts to derive new facts |
|
||||||
|
| `get_graph_analytics` | PageRank centrality and community detection |
|
||||||
|
| `export_graph` | Export graph as Turtle, JSON-LD, N-Triples, or JSON |
|
||||||
|
| `get_graph_summary` | Node count, decision count, graph status |
|
||||||
|
|
||||||
After installing on any platform:
|
### Available MCP resources
|
||||||
|
|
||||||
```
|
| URI | Description |
|
||||||
/semantica:decision record <category> "<scenario>" "<reasoning>" <outcome> <confidence>
|
|---|---|
|
||||||
/semantica:decision list
|
| `semantica://graph/summary` | High-level graph statistics |
|
||||||
/semantica:causal chain --subject <decision_id> --depth 3
|
| `semantica://decisions/list` | All recorded decisions |
|
||||||
/semantica:explain decision <decision_id>
|
| `semantica://schema/info` | Server info and capability list |
|
||||||
/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
|
## Community Notes
|
||||||
|
|
||||||
- Keep `name` / `version` / `keywords` updated in each manifest before publishing.
|
- Keep plugin name/version/keywords updated in each manifest before publishing.
|
||||||
- Keep skill frontmatter (`name` + `description`) consistent for reliable discovery.
|
- Keep skill frontmatter consistent (`name` + `description`) for reliable discovery.
|
||||||
- Include `plugins/` as-is when sharing — skills, agents, and hooks must stay bundled.
|
- For open-source sharing, include this folder as-is so skills, agents, and hooks remain bundled.
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "semantica-local",
|
"name": "semantica-local",
|
||||||
"owner": {
|
|
||||||
"name": "Hawksight AI",
|
|
||||||
"url": "https://github.com/Hawksight-AI/semantica"
|
|
||||||
},
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "semantica",
|
"name": "semantica",
|
||||||
|
|||||||
@@ -25,5 +25,6 @@
|
|||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"agents": "./agents"
|
"agents": "./agents",
|
||||||
|
"hooks": "./hooks/hooks.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Semantica — Cline Plugin
|
# Semantica — Cline Plugin
|
||||||
|
|
||||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
|
Adds all 17 Semantica skills, 3 agents, and hook configuration to Cline (VS Code extension).
|
||||||
|
|
||||||
## MCP Server Setup (recommended)
|
## MCP Server Setup (recommended)
|
||||||
|
|
||||||
@@ -16,17 +16,9 @@ In Cline settings, add a new MCP server:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Cline will discover all 17 Semantica skills and 3 agents automatically on connection.
|
Cline will discover all 12 Semantica tools automatically on connection.
|
||||||
|
|
||||||
## Knowledge Explorer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
semantica-explorer --graph my_graph.json --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:5174` for the interactive dashboard.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.8+
|
||||||
- `pip install semantica`
|
- `pip install semantica`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Semantica — Continue Plugin
|
# Semantica — Continue Plugin
|
||||||
|
|
||||||
> **v0.4.0** — Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
|
Adds Semantica as an MCP server and context provider to [Continue.dev](https://continue.dev).
|
||||||
|
|
||||||
## MCP Server Setup
|
## MCP Server Setup
|
||||||
|
|
||||||
@@ -18,17 +18,9 @@ Add to `~/.continue/config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Continue will show all 17 Semantica skills in the `@semantica` context provider dropdown.
|
Continue will show all Semantica tools 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
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.8+
|
||||||
- `pip install semantica`
|
- `pip install semantica`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Semantica — OpenClaw Plugin
|
# Semantica — OpenClaw Plugin
|
||||||
|
|
||||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and the full MCP integration to [OpenClaw](https://openclaw.ai) — the open-source personal AI agent platform.
|
Adds all 17 Semantica skills, 3 agents, and the full MCP integration to [OpenClaw](https://openclaw.ai) — the open-source personal AI agent platform.
|
||||||
|
|
||||||
## MCP Server Setup (recommended)
|
## MCP Server Setup (recommended)
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ Paste the following into your OpenClaw `mcporter.json` (usually `~/.openclaw/mcp
|
|||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenClaw will automatically discover all 17 Semantica tools and 3 agents.
|
OpenClaw will automatically discover all 12 Semantica tools and 3 resources.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
@@ -57,6 +57,6 @@ See [`integrations/openclaw/README.md`](../../integrations/openclaw/README.md) f
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.8+
|
||||||
- `pip install semantica`
|
- `pip install semantica`
|
||||||
- OpenClaw — [openclaw.ai](https://openclaw.ai)
|
- OpenClaw — [openclaw.ai](https://openclaw.ai)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Semantica — VS Code Plugin
|
# Semantica — VS Code Plugin
|
||||||
|
|
||||||
> **v0.4.0** — Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
|
Adds Semantica as an MCP server to VS Code (via GitHub Copilot Chat or any MCP-aware extension).
|
||||||
|
|
||||||
## MCP Server Setup
|
## MCP Server Setup
|
||||||
|
|
||||||
@@ -30,19 +30,7 @@ Or if using the VS Code MCP extension directly:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
VS Code will discover all 17 Semantica skills and 3 agents automatically on connection.
|
|
||||||
|
|
||||||
## Knowledge Explorer
|
|
||||||
|
|
||||||
Launch the interactive graph dashboard from the terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
semantica-explorer --graph my_graph.json --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:5174` to explore nodes, edges, decisions, SPARQL, lineage, and more.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.8+
|
||||||
- `pip install semantica`
|
- `pip install semantica`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Semantica — Windsurf Plugin
|
# Semantica — Windsurf Plugin
|
||||||
|
|
||||||
> **v0.4.0** — Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
|
Adds all 17 Semantica skills, 3 agents, and hook configuration to Windsurf.
|
||||||
|
|
||||||
## MCP Server Setup (recommended)
|
## MCP Server Setup (recommended)
|
||||||
|
|
||||||
@@ -17,23 +17,13 @@ Add to your Windsurf MCP config (`~/.codeium/windsurf/mcp_config.json`):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Windsurf will have access to all 17 Semantica skills (`extract`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, and more) directly in the AI panel.
|
Windsurf will then have access to all 12 Semantica tools (extract, record_decision, query_decisions, find_precedents, get_causal_chain, add_entity, add_relationship, run_reasoning, get_graph_analytics, export_graph, and more) directly in the AI panel.
|
||||||
|
|
||||||
## Skills
|
## 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
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.8+
|
||||||
- `pip install semantica`
|
- `pip install semantica`
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
|
|||||||
llm-gemini = ["google-genai>=0.1.0"]
|
llm-gemini = ["google-genai>=0.1.0"]
|
||||||
llm-anthropic = ["anthropic>=0.18.0"]
|
llm-anthropic = ["anthropic>=0.18.0"]
|
||||||
llm-ollama = ["ollama>=0.1.0"]
|
llm-ollama = ["ollama>=0.1.0"]
|
||||||
llm-deepseek = ["openai>=1.0.0"]
|
llm-deepseek = ["deepseek>=0.1.0"]
|
||||||
llm-litellm = ["litellm>=1.0.0"]
|
llm-litellm = ["litellm>=1.0.0"]
|
||||||
llm-instructor = ["instructor>=1.0.0"]
|
llm-instructor = ["instructor>=1.0.0"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Main exports:
|
|||||||
- Config: Configuration management
|
- Config: Configuration management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.3.0"
|
||||||
__author__ = "Semantica Contributors"
|
__author__ = "Semantica Contributors"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .. import __version__
|
from .. import __version__
|
||||||
@@ -19,12 +19,7 @@ from .ws import ConnectionManager
|
|||||||
|
|
||||||
|
|
||||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
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:
|
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)
|
loop = getattr(app.state, "event_loop", None)
|
||||||
manager = getattr(app.state, "ws_manager", None)
|
manager = getattr(app.state, "ws_manager", None)
|
||||||
if loop is None or manager is None or loop.is_closed():
|
if loop is None or manager is None or loop.is_closed():
|
||||||
@@ -132,17 +127,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket)
|
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")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
@@ -155,17 +139,6 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
|||||||
"status": "active",
|
"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"
|
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||||
if static_dir.is_dir():
|
if static_dir.is_dir():
|
||||||
assets_dir = static_dir / "assets"
|
assets_dir = static_dir / "assets"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ modify Semantica core.
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import AnnotationCreate, AnnotationResponse
|
from ..schemas import AnnotationCreate, AnnotationResponse
|
||||||
@@ -35,7 +35,7 @@ async def create_annotation(
|
|||||||
"""Create a new annotation on a node."""
|
"""Create a new annotation on a node."""
|
||||||
node = await asyncio.to_thread(session.get_node, body.node_id)
|
node = await asyncio.to_thread(session.get_node, body.node_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
|
raise KeyError(body.node_id)
|
||||||
|
|
||||||
ann_data = body.model_dump()
|
ann_data = body.model_dump()
|
||||||
ann_id = await asyncio.to_thread(session.add_annotation, ann_data)
|
ann_id = await asyncio.to_thread(session.add_annotation, ann_data)
|
||||||
@@ -61,5 +61,5 @@ async def delete_annotation(
|
|||||||
"""Delete an annotation by ID."""
|
"""Delete an annotation by ID."""
|
||||||
deleted = await asyncio.to_thread(session.delete_annotation, annotation_id)
|
deleted = await asyncio.to_thread(session.delete_annotation, annotation_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Annotation '{annotation_id}' not found")
|
raise KeyError(annotation_id)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Decision routes using ContextGraph-native fallbacks.
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||||
@@ -59,7 +59,7 @@ async def get_decision(
|
|||||||
):
|
):
|
||||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
raise KeyError(decision_id)
|
||||||
return _node_to_decision(node)
|
return _node_to_decision(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ async def get_causal_chain(
|
|||||||
):
|
):
|
||||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
raise KeyError(decision_id)
|
||||||
|
|
||||||
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, 5)
|
neighbors = await asyncio.to_thread(session.get_neighbors, decision_id, 5)
|
||||||
chain = [
|
chain = [
|
||||||
@@ -94,7 +94,7 @@ async def get_precedents(
|
|||||||
):
|
):
|
||||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
raise KeyError(decision_id)
|
||||||
|
|
||||||
properties = node.get("properties", {})
|
properties = node.get("properties", {})
|
||||||
category = str(properties.get("category", ""))
|
category = str(properties.get("category", ""))
|
||||||
@@ -132,7 +132,7 @@ async def check_compliance(
|
|||||||
):
|
):
|
||||||
node = await asyncio.to_thread(session.get_node, decision_id)
|
node = await asyncio.to_thread(session.get_node, decision_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Decision '{decision_id}' not found")
|
raise KeyError(decision_id)
|
||||||
|
|
||||||
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||||
violation_types = {"violates", "non_compliant", "breaches"}
|
violation_types = {"violates", "non_compliant", "breaches"}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
|
|||||||
continue
|
continue
|
||||||
source, target = args
|
source, target = args
|
||||||
if session.get_node(source) is None:
|
if session.get_node(source) is None:
|
||||||
session.add_node(source, "entity", content=source)
|
session.graph.add_node(source, "entity", content=source)
|
||||||
if session.get_node(target) is None:
|
if session.get_node(target) is None:
|
||||||
session.add_node(target, "entity", content=target)
|
session.graph.add_node(target, "entity", content=target)
|
||||||
edge_type = body.inferred_edge_type or predicate
|
edge_type = body.inferred_edge_type or predicate
|
||||||
session.add_edge(
|
session.graph.add_edge(
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
edge_type=edge_type,
|
edge_type=edge_type,
|
||||||
@@ -173,12 +173,11 @@ async def extract_entities(
|
|||||||
relations=[_safe_dict(relation) for relation in rel_list],
|
relations=[_safe_dict(relation) for relation in rel_list],
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(
|
raise ValueError(
|
||||||
status_code=503,
|
"semantic_extract module not available. Ensure spacy and transformers are installed."
|
||||||
detail="semantic_extract module not available. Ensure spacy and transformers are installed.",
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Extraction failed: {exc}")
|
raise ValueError(f"Extraction failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
|
@router.post("/api/enrich/links", response_model=LinkPredictionResponse)
|
||||||
@@ -188,11 +187,11 @@ async def predict_links(
|
|||||||
):
|
):
|
||||||
predictor = session.link_predictor
|
predictor = session.link_predictor
|
||||||
if predictor is None:
|
if predictor is None:
|
||||||
raise HTTPException(status_code=503, detail="LinkPredictor not available; KG extras may not be installed.")
|
raise ValueError("LinkPredictor not available; KG extras may not be installed.")
|
||||||
|
|
||||||
node = await asyncio.to_thread(session.get_node, body.node_id)
|
node = await asyncio.to_thread(session.get_node, body.node_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Node '{body.node_id}' not found")
|
raise KeyError(body.node_id)
|
||||||
|
|
||||||
nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
|
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)
|
edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
|
||||||
@@ -249,9 +248,9 @@ async def detect_duplicates(
|
|||||||
duplicate_list = duplicates if isinstance(duplicates, list) else getattr(duplicates, "duplicates", [])
|
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))
|
return DedupResponse(duplicates=[_safe_dict(item) for item in duplicate_list], total_flagged=len(duplicate_list))
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise HTTPException(status_code=503, detail="Deduplication module not available.")
|
raise ValueError("Deduplication module not available.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Dedup scan failed: {exc}")
|
raise ValueError(f"Dedup scan failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/reason", response_model=ReasoningResponse)
|
@router.post("/api/reason", response_model=ReasoningResponse)
|
||||||
@@ -296,7 +295,7 @@ async def merge_nodes(
|
|||||||
|
|
||||||
node = await asyncio.to_thread(session.get_node, primary_id)
|
node = await asyncio.to_thread(session.get_node, primary_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Primary node '{primary_id}' not found")
|
raise ValueError(f"Primary node {primary_id} not found")
|
||||||
|
|
||||||
def _do_merge() -> tuple[list[str], int]:
|
def _do_merge() -> tuple[list[str], int]:
|
||||||
removed: list[str] = []
|
removed: list[str] = []
|
||||||
@@ -354,6 +353,4 @@ async def merge_nodes(
|
|||||||
return removed, edges_updated
|
return removed, edges_updated
|
||||||
|
|
||||||
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
|
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)
|
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import asyncio
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from ...utils.helpers import classify_path_distance
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
EdgeListResponse,
|
EdgeListResponse,
|
||||||
@@ -84,7 +83,7 @@ async def get_node(
|
|||||||
):
|
):
|
||||||
node = await asyncio.to_thread(session.get_node, node_id)
|
node = await asyncio.to_thread(session.get_node, node_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
raise KeyError(node_id)
|
||||||
return _node_response(node)
|
return _node_response(node)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,19 +141,16 @@ class _PathAlgorithm(str, Enum):
|
|||||||
dijkstra = "dijkstra"
|
dijkstra = "dijkstra"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||||
async def find_path(
|
async def find_path(
|
||||||
node_id: str,
|
node_id: str,
|
||||||
target: str = Query(..., description="Target node ID"),
|
target: str = Query(..., description="Target node ID"),
|
||||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
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),
|
session: GraphSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
path_finder = session.path_finder
|
path_finder = session.path_finder
|
||||||
if path_finder is None:
|
if path_finder is None:
|
||||||
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
|
raise ValueError("PathFinder not available; KG extras may not be installed.")
|
||||||
|
|
||||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||||
path_fn = (
|
path_fn = (
|
||||||
@@ -162,19 +158,12 @@ async def find_path(
|
|||||||
if algorithm == _PathAlgorithm.dijkstra
|
if algorithm == _PathAlgorithm.dijkstra
|
||||||
else path_finder.bfs_shortest_path
|
else path_finder.bfs_shortest_path
|
||||||
)
|
)
|
||||||
try:
|
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
|
||||||
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 [])
|
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
|
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)
|
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(
|
return PathResponse(
|
||||||
source=node_id,
|
source=node_id,
|
||||||
target=target,
|
target=target,
|
||||||
@@ -182,9 +171,6 @@ async def find_path(
|
|||||||
path=path_nodes,
|
path=path_nodes,
|
||||||
edge_ids=edge_ids,
|
edge_ids=edge_ids,
|
||||||
total_weight=total_weight,
|
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.
|
Provenance routes for lineage visualization and exportable reports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -9,13 +9,33 @@ from typing import Any, Dict, List, Optional
|
|||||||
import networkx as nx
|
import networkx as nx
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from fastapi.responses import PlainTextResponse, Response
|
from fastapi.responses import PlainTextResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..dependencies import get_session
|
from ..dependencies import get_session
|
||||||
from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
|
|
||||||
from ..session import GraphSession
|
from ..session import GraphSession
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
|
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"}
|
_AGENT_TYPES = {"person", "organization", "system", "agent"}
|
||||||
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
|
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
|
||||||
|
|
||||||
@@ -47,7 +67,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
|||||||
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
|
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)
|
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
|
||||||
|
|
||||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
|
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
|
||||||
provenance_nodes: List[Dict[str, Any]] = []
|
provenance_nodes: List[Dict[str, Any]] = []
|
||||||
for graph_node_id in subgraph.nodes():
|
for graph_node_id in subgraph.nodes():
|
||||||
node = session.graph.nodes.get(graph_node_id)
|
node = session.graph.nodes.get(graph_node_id)
|
||||||
@@ -65,19 +85,12 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
|||||||
|
|
||||||
provenance_edges: List[Dict[str, Any]] = []
|
provenance_edges: List[Dict[str, Any]] = []
|
||||||
for source, target, data in subgraph.edges(data=True):
|
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(
|
provenance_edges.append(
|
||||||
{
|
{
|
||||||
"id": f"{source}-{target}",
|
"id": f"{source}-{target}",
|
||||||
"source": source,
|
"source": source,
|
||||||
"target": target,
|
"target": target,
|
||||||
"label": data.get("label", "related_to"),
|
"label": data.get("label", "related_to"),
|
||||||
"direction": direction,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,7 +104,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
|
|||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
"label": node.get("content", node_id) if node else node_id,
|
"label": node.get("content", node_id) if node else node_id,
|
||||||
"type": node.get("type", "entity") if node else "entity",
|
"type": node.get("type", "entity") if node else "entity",
|
||||||
"properties": node.get("metadata", node.get("properties", {})) if node else {},
|
"properties": node.get("properties", {}) if node else {},
|
||||||
"lineage": provenance,
|
"lineage": provenance,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,29 +129,9 @@ def _render_markdown(report: Dict[str, Any]) -> str:
|
|||||||
for node in report.get("lineage", {}).get("nodes", []):
|
for node in report.get("lineage", {}).get("nodes", []):
|
||||||
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
|
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
|
||||||
|
|
||||||
edges = report.get("lineage", {}).get("edges", [])
|
lines.extend(["", "## Lineage Edges"])
|
||||||
grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
|
for edge in report.get("lineage", {}).get("edges", []):
|
||||||
for edge in edges:
|
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||||
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def temporal_patterns(
|
|||||||
|
|
||||||
detector = TemporalPatternDetector()
|
detector = TemporalPatternDetector()
|
||||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||||
patterns = await asyncio.to_thread(detector.detect_temporal_patterns, graph_dict)
|
patterns = await asyncio.to_thread(detector.detect_patterns, graph_dict)
|
||||||
if isinstance(patterns, dict):
|
if isinstance(patterns, dict):
|
||||||
patterns = patterns.get("patterns", [])
|
patterns = patterns.get("patterns", [])
|
||||||
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
|
return TemporalPatternResponse(patterns=patterns if isinstance(patterns, list) else [])
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ class PathResponse(BaseModel):
|
|||||||
path: List[str]
|
path: List[str]
|
||||||
edge_ids: List[str] = Field(default_factory=list)
|
edge_ids: List[str] = Field(default_factory=list)
|
||||||
total_weight: float = 0.0
|
total_weight: float = 0.0
|
||||||
directed: bool = True
|
|
||||||
hop_count: int = 0
|
|
||||||
distance_band: str = "direct"
|
|
||||||
|
|
||||||
|
|
||||||
class GraphStatsResponse(BaseModel):
|
class GraphStatsResponse(BaseModel):
|
||||||
@@ -288,23 +285,3 @@ class MergeResponse(BaseModel):
|
|||||||
merged_into: str
|
merged_into: str
|
||||||
removed_ids: List[str]
|
removed_ids: List[str]
|
||||||
edges_updated: int
|
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]
|
|
||||||
|
|||||||
@@ -1,398 +0,0 @@
|
|||||||
"""
|
|
||||||
Explorer-local in-memory node search index.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import bisect
|
|
||||||
import heapq
|
|
||||||
import re
|
|
||||||
from collections import OrderedDict, defaultdict
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple
|
|
||||||
|
|
||||||
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
||||||
_WHITESPACE_RE = re.compile(r"\s+")
|
|
||||||
_CURATED_ALIAS_KEYS = (
|
|
||||||
"label",
|
|
||||||
"name",
|
|
||||||
"title",
|
|
||||||
"pref_label",
|
|
||||||
"preferred_label",
|
|
||||||
"prefLabel",
|
|
||||||
"aliases",
|
|
||||||
"alias",
|
|
||||||
"synonyms",
|
|
||||||
"synonym",
|
|
||||||
"symbol",
|
|
||||||
"display_name",
|
|
||||||
"displayName",
|
|
||||||
"text",
|
|
||||||
"content",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_text(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
text = str(value).strip().lower()
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
return _WHITESPACE_RE.sub(" ", text)
|
|
||||||
|
|
||||||
|
|
||||||
def _tokenize(text: str) -> Tuple[str, ...]:
|
|
||||||
if not text:
|
|
||||||
return ()
|
|
||||||
return tuple(dict.fromkeys(_TOKEN_RE.findall(text)))
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_text_fragments(value: Any, fragments: List[str], *, limit: int = 64) -> None:
|
|
||||||
if value is None or len(fragments) >= limit:
|
|
||||||
return
|
|
||||||
if isinstance(value, dict):
|
|
||||||
for nested in value.values():
|
|
||||||
_collect_text_fragments(nested, fragments, limit=limit)
|
|
||||||
if len(fragments) >= limit:
|
|
||||||
return
|
|
||||||
return
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
for nested in value:
|
|
||||||
_collect_text_fragments(nested, fragments, limit=limit)
|
|
||||||
if len(fragments) >= limit:
|
|
||||||
return
|
|
||||||
return
|
|
||||||
|
|
||||||
normalized = _normalize_text(value)
|
|
||||||
if normalized:
|
|
||||||
fragments.append(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_float(value: Any) -> Optional[float]:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class IndexedNodeDocument:
|
|
||||||
node_id: str
|
|
||||||
normalized_id: str
|
|
||||||
node_type: str
|
|
||||||
exact_terms: frozenset[str]
|
|
||||||
tokens: frozenset[str]
|
|
||||||
primary_text: str
|
|
||||||
secondary_text: str
|
|
||||||
confidence: Optional[float]
|
|
||||||
tags: Tuple[str, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class GraphSearchIndex:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
cache_size: int = 128,
|
|
||||||
prefix_min_length: int = 2,
|
|
||||||
prefix_max_length: int = 12,
|
|
||||||
secondary_scan_limit: int = 12000,
|
|
||||||
) -> None:
|
|
||||||
self.cache_size = cache_size
|
|
||||||
self.prefix_min_length = prefix_min_length
|
|
||||||
self.prefix_max_length = prefix_max_length
|
|
||||||
self.secondary_scan_limit = secondary_scan_limit
|
|
||||||
self._documents: Dict[str, IndexedNodeDocument] = {}
|
|
||||||
self._exact_index: DefaultDict[str, set[str]] = defaultdict(set)
|
|
||||||
self._token_index: DefaultDict[str, set[str]] = defaultdict(set)
|
|
||||||
self._prefix_index: DefaultDict[str, set[str]] = defaultdict(set)
|
|
||||||
self._ordered_node_ids: List[str] = []
|
|
||||||
self._cache: OrderedDict[Tuple[Any, ...], List[Tuple[str, float]]] = OrderedDict()
|
|
||||||
|
|
||||||
def rebuild(self, nodes: Iterable[Dict[str, Any]]) -> None:
|
|
||||||
self._documents.clear()
|
|
||||||
self._exact_index.clear()
|
|
||||||
self._token_index.clear()
|
|
||||||
self._prefix_index.clear()
|
|
||||||
self._ordered_node_ids = []
|
|
||||||
self.clear_cache()
|
|
||||||
|
|
||||||
for node in nodes:
|
|
||||||
self.upsert(node, clear_cache=False)
|
|
||||||
|
|
||||||
self._ordered_node_ids.sort()
|
|
||||||
|
|
||||||
def clear_cache(self) -> None:
|
|
||||||
self._cache.clear()
|
|
||||||
|
|
||||||
def remove(self, node_id: str, *, clear_cache: bool = True) -> None:
|
|
||||||
existing = self._documents.pop(node_id, None)
|
|
||||||
if existing is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
for term in existing.exact_terms:
|
|
||||||
bucket = self._exact_index.get(term)
|
|
||||||
if bucket is None:
|
|
||||||
continue
|
|
||||||
bucket.discard(node_id)
|
|
||||||
if not bucket:
|
|
||||||
self._exact_index.pop(term, None)
|
|
||||||
|
|
||||||
for token in existing.tokens:
|
|
||||||
bucket = self._token_index.get(token)
|
|
||||||
if bucket is None:
|
|
||||||
continue
|
|
||||||
bucket.discard(node_id)
|
|
||||||
if not bucket:
|
|
||||||
self._token_index.pop(token, None)
|
|
||||||
|
|
||||||
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
|
|
||||||
prefix = token[:length]
|
|
||||||
prefix_bucket = self._prefix_index.get(prefix)
|
|
||||||
if prefix_bucket is None:
|
|
||||||
continue
|
|
||||||
prefix_bucket.discard(node_id)
|
|
||||||
if not prefix_bucket:
|
|
||||||
self._prefix_index.pop(prefix, None)
|
|
||||||
|
|
||||||
pos = bisect.bisect_left(self._ordered_node_ids, node_id)
|
|
||||||
if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
|
|
||||||
self._ordered_node_ids.pop(pos)
|
|
||||||
|
|
||||||
if clear_cache:
|
|
||||||
self.clear_cache()
|
|
||||||
|
|
||||||
def upsert(self, node: Dict[str, Any], *, clear_cache: bool = True) -> None:
|
|
||||||
node_id = str(node.get("id", "")).strip()
|
|
||||||
if not node_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.remove(node_id, clear_cache=False)
|
|
||||||
document = self._build_document(node)
|
|
||||||
self._documents[node_id] = document
|
|
||||||
|
|
||||||
for term in document.exact_terms:
|
|
||||||
self._exact_index[term].add(node_id)
|
|
||||||
|
|
||||||
for token in document.tokens:
|
|
||||||
self._token_index[token].add(node_id)
|
|
||||||
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
|
|
||||||
self._prefix_index[token[:length]].add(node_id)
|
|
||||||
|
|
||||||
bisect.insort(self._ordered_node_ids, node_id)
|
|
||||||
|
|
||||||
if clear_cache:
|
|
||||||
self.clear_cache()
|
|
||||||
|
|
||||||
def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
*,
|
|
||||||
limit: int = 20,
|
|
||||||
filters: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> tuple[List[Tuple[str, float]], Dict[str, Any]]:
|
|
||||||
normalized_query = _normalize_text(query)
|
|
||||||
filters = filters or {}
|
|
||||||
diagnostics: Dict[str, Any] = {
|
|
||||||
"cache_hit": False,
|
|
||||||
"path": "empty",
|
|
||||||
"candidates": 0,
|
|
||||||
}
|
|
||||||
if not normalized_query:
|
|
||||||
return [], diagnostics
|
|
||||||
|
|
||||||
cache_key = self._cache_key(normalized_query, limit, filters)
|
|
||||||
cached = self._cache.get(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
self._cache.move_to_end(cache_key)
|
|
||||||
diagnostics.update({"cache_hit": True, "path": "cache", "candidates": len(cached)})
|
|
||||||
return list(cached), diagnostics
|
|
||||||
|
|
||||||
query_tokens = _tokenize(normalized_query)
|
|
||||||
exact_ids = set(self._exact_index.get(normalized_query, set()))
|
|
||||||
token_sets: List[set[str]] = []
|
|
||||||
prefix_sets: List[set[str]] = []
|
|
||||||
for token in query_tokens:
|
|
||||||
exact_token_ids = set(self._token_index.get(token, set()))
|
|
||||||
prefix_ids = set(self._prefix_index.get(token, set())) if len(token) >= self.prefix_min_length else set()
|
|
||||||
if exact_token_ids:
|
|
||||||
token_sets.append(exact_token_ids)
|
|
||||||
if prefix_ids:
|
|
||||||
prefix_sets.append(prefix_ids)
|
|
||||||
|
|
||||||
candidate_ids: set[str] = set(exact_ids)
|
|
||||||
if token_sets:
|
|
||||||
intersected = set.intersection(*token_sets)
|
|
||||||
candidate_ids.update(intersected if intersected else set().union(*token_sets))
|
|
||||||
if prefix_sets:
|
|
||||||
candidate_ids.update(set().union(*prefix_sets))
|
|
||||||
|
|
||||||
diagnostics["path"] = "index"
|
|
||||||
|
|
||||||
if not candidate_ids:
|
|
||||||
diagnostics["path"] = "secondary_scan"
|
|
||||||
candidate_ids = self._secondary_scan(normalized_query, limit)
|
|
||||||
|
|
||||||
diagnostics["candidates"] = len(candidate_ids)
|
|
||||||
|
|
||||||
scored: List[Tuple[float, int, int, str]] = []
|
|
||||||
for node_id in candidate_ids:
|
|
||||||
document = self._documents.get(node_id)
|
|
||||||
if document is None or not self._passes_filters(document, filters):
|
|
||||||
continue
|
|
||||||
score = self._score_document(document, normalized_query, query_tokens)
|
|
||||||
if score <= 0:
|
|
||||||
continue
|
|
||||||
token_hits = sum(1 for token in query_tokens if token in document.tokens)
|
|
||||||
exactness = 1 if normalized_query == document.normalized_id or normalized_query in document.exact_terms else 0
|
|
||||||
scored.append((score, exactness, token_hits, node_id))
|
|
||||||
|
|
||||||
top_matches = heapq.nlargest(limit, scored, key=lambda item: (item[0], item[1], item[2], item[3]))
|
|
||||||
results = [(node_id, round(score, 4)) for score, _, _, node_id in top_matches]
|
|
||||||
self._store_cache(cache_key, results)
|
|
||||||
return results, diagnostics
|
|
||||||
|
|
||||||
def _secondary_scan(self, normalized_query: str, limit: int) -> set[str]:
|
|
||||||
matches: set[str] = set()
|
|
||||||
max_hits = max(limit * 20, 200)
|
|
||||||
scanned = 0
|
|
||||||
for node_id in self._ordered_node_ids:
|
|
||||||
if scanned >= self.secondary_scan_limit or len(matches) >= max_hits:
|
|
||||||
break
|
|
||||||
scanned += 1
|
|
||||||
document = self._documents.get(node_id)
|
|
||||||
if document is None:
|
|
||||||
continue
|
|
||||||
if normalized_query in document.primary_text or normalized_query in document.secondary_text:
|
|
||||||
matches.add(node_id)
|
|
||||||
return matches
|
|
||||||
|
|
||||||
def _score_document(
|
|
||||||
self,
|
|
||||||
document: IndexedNodeDocument,
|
|
||||||
normalized_query: str,
|
|
||||||
query_tokens: Tuple[str, ...],
|
|
||||||
) -> float:
|
|
||||||
score = 0.0
|
|
||||||
if normalized_query == document.normalized_id:
|
|
||||||
score = max(score, 140.0)
|
|
||||||
elif normalized_query in document.exact_terms:
|
|
||||||
score = max(score, 120.0)
|
|
||||||
|
|
||||||
if normalized_query and normalized_query in document.primary_text:
|
|
||||||
score = max(score, 78.0 + min(len(normalized_query), 24) / 10.0)
|
|
||||||
elif normalized_query and normalized_query in document.secondary_text:
|
|
||||||
score = max(score, 26.0 + min(len(normalized_query), 24) / 20.0)
|
|
||||||
|
|
||||||
token_hits = 0
|
|
||||||
prefix_hits = 0
|
|
||||||
for token in query_tokens:
|
|
||||||
if token in document.tokens:
|
|
||||||
token_hits += 1
|
|
||||||
elif len(token) >= self.prefix_min_length and any(candidate.startswith(token) for candidate in document.tokens):
|
|
||||||
prefix_hits += 1
|
|
||||||
|
|
||||||
score += token_hits * 18.0
|
|
||||||
score += prefix_hits * 10.0
|
|
||||||
|
|
||||||
if len(query_tokens) > 1 and token_hits:
|
|
||||||
score += token_hits * 4.0
|
|
||||||
|
|
||||||
return score
|
|
||||||
|
|
||||||
def _passes_filters(self, document: IndexedNodeDocument, filters: Dict[str, Any]) -> bool:
|
|
||||||
filter_type = filters.get("type") or filters.get("node_type")
|
|
||||||
if filter_type and document.node_type != str(filter_type):
|
|
||||||
return False
|
|
||||||
|
|
||||||
min_confidence = _coerce_float(filters.get("min_confidence"))
|
|
||||||
if min_confidence is not None:
|
|
||||||
if document.confidence is None or document.confidence < min_confidence:
|
|
||||||
return False
|
|
||||||
|
|
||||||
tags_filter = filters.get("tags")
|
|
||||||
if tags_filter:
|
|
||||||
if isinstance(tags_filter, str):
|
|
||||||
required_tags = {_normalize_text(tags_filter)}
|
|
||||||
else:
|
|
||||||
required_tags = {
|
|
||||||
normalized
|
|
||||||
for normalized in (_normalize_text(tag) for tag in tags_filter)
|
|
||||||
if normalized
|
|
||||||
}
|
|
||||||
if required_tags and not required_tags.issubset(set(document.tags)):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _cache_key(
|
|
||||||
self,
|
|
||||||
normalized_query: str,
|
|
||||||
limit: int,
|
|
||||||
filters: Dict[str, Any],
|
|
||||||
) -> Tuple[Any, ...]:
|
|
||||||
serialized_filters: List[Tuple[str, Any]] = []
|
|
||||||
for key in sorted(filters.keys()):
|
|
||||||
value = filters[key]
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
|
|
||||||
else:
|
|
||||||
serialized_filters.append((key, str(value)))
|
|
||||||
return normalized_query, limit, tuple(serialized_filters)
|
|
||||||
|
|
||||||
def _store_cache(self, cache_key: Tuple[Any, ...], results: List[Tuple[str, float]]) -> None:
|
|
||||||
self._cache[cache_key] = list(results)
|
|
||||||
self._cache.move_to_end(cache_key)
|
|
||||||
while len(self._cache) > self.cache_size:
|
|
||||||
self._cache.popitem(last=False)
|
|
||||||
|
|
||||||
def _build_document(self, node: Dict[str, Any]) -> IndexedNodeDocument:
|
|
||||||
node_id = str(node.get("id", "")).strip()
|
|
||||||
node_type = str(node.get("type", "entity"))
|
|
||||||
properties = dict(node.get("properties", {}) or {})
|
|
||||||
|
|
||||||
primary_terms: List[str] = []
|
|
||||||
for candidate in (node_id, node.get("content", "")):
|
|
||||||
normalized = _normalize_text(candidate)
|
|
||||||
if normalized:
|
|
||||||
primary_terms.append(normalized)
|
|
||||||
|
|
||||||
for alias_key in _CURATED_ALIAS_KEYS:
|
|
||||||
_collect_text_fragments(properties.get(alias_key), primary_terms, limit=32)
|
|
||||||
|
|
||||||
deduped_primary_terms = tuple(dict.fromkeys(term for term in primary_terms if term))
|
|
||||||
primary_text = " ".join(deduped_primary_terms)
|
|
||||||
tokens = frozenset(_tokenize(primary_text))
|
|
||||||
|
|
||||||
secondary_fragments: List[str] = []
|
|
||||||
for key, value in properties.items():
|
|
||||||
if key in _CURATED_ALIAS_KEYS or key in {"content", "valid_from", "valid_until"}:
|
|
||||||
continue
|
|
||||||
_collect_text_fragments(value, secondary_fragments, limit=48)
|
|
||||||
if len(secondary_fragments) >= 48:
|
|
||||||
break
|
|
||||||
|
|
||||||
secondary_text = " ".join(dict.fromkeys(fragment for fragment in secondary_fragments if fragment))
|
|
||||||
confidence = _coerce_float(properties.get("confidence"))
|
|
||||||
|
|
||||||
raw_tags = properties.get("tags") or []
|
|
||||||
if isinstance(raw_tags, str):
|
|
||||||
raw_tags = [raw_tags]
|
|
||||||
tags = tuple(
|
|
||||||
dict.fromkeys(
|
|
||||||
normalized for normalized in (_normalize_text(tag) for tag in raw_tags) if normalized
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return IndexedNodeDocument(
|
|
||||||
node_id=node_id,
|
|
||||||
normalized_id=_normalize_text(node_id),
|
|
||||||
node_type=node_type,
|
|
||||||
exact_terms=frozenset(deduped_primary_terms),
|
|
||||||
tokens=tokens,
|
|
||||||
primary_text=primary_text,
|
|
||||||
secondary_text=secondary_text,
|
|
||||||
confidence=confidence,
|
|
||||||
tags=tags,
|
|
||||||
)
|
|
||||||
+58
-100
@@ -4,15 +4,12 @@ Semantica Explorer session helpers.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime, UTC
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
||||||
from .search_index import GraphSearchIndex
|
|
||||||
|
|
||||||
_KG_AVAILABLE = False
|
_KG_AVAILABLE = False
|
||||||
try:
|
try:
|
||||||
@@ -31,8 +28,6 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class GraphSession:
|
class GraphSession:
|
||||||
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
|
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
|
||||||
@@ -40,7 +35,6 @@ class GraphSession:
|
|||||||
def __init__(self, graph: ContextGraph) -> None:
|
def __init__(self, graph: ContextGraph) -> None:
|
||||||
self.graph = graph
|
self.graph = graph
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._search_index = GraphSearchIndex()
|
|
||||||
|
|
||||||
self.annotations: Dict[str, Dict[str, Any]] = {}
|
self.annotations: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
@@ -52,7 +46,6 @@ class GraphSession:
|
|||||||
self._similarity: Any = None
|
self._similarity: Any = None
|
||||||
self._link_predictor: Any = None
|
self._link_predictor: Any = None
|
||||||
self._validator: Any = None
|
self._validator: Any = None
|
||||||
self.rebuild_search_index()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, path: str) -> "GraphSession":
|
def from_file(cls, path: str) -> "GraphSession":
|
||||||
@@ -397,28 +390,6 @@ class GraphSession:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self.graph.get_neighbors(node_id, hops=depth)
|
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(
|
def search(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -426,34 +397,64 @@ class GraphSession:
|
|||||||
filters: Optional[Dict[str, Any]] = None,
|
filters: Optional[Dict[str, Any]] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
filters = filters or {}
|
filters = filters or {}
|
||||||
started_at = time.perf_counter()
|
try:
|
||||||
matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
|
with self._lock:
|
||||||
|
raw = self.graph.query(query)[:limit]
|
||||||
|
except Exception:
|
||||||
|
raw = []
|
||||||
|
|
||||||
normalized_results: List[Dict[str, Any]] = []
|
if not raw:
|
||||||
with self._lock:
|
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
|
||||||
for node_id, score in matches:
|
scored = []
|
||||||
raw_node = self.graph.find_node(node_id)
|
lowered_query = query.lower().strip()
|
||||||
if raw_node is None:
|
for node in nodes:
|
||||||
|
haystacks = [
|
||||||
|
str(node.get("id", "")),
|
||||||
|
str(node.get("content", "")),
|
||||||
|
json.dumps(node.get("properties", {}), default=str),
|
||||||
|
]
|
||||||
|
best_score = 0.0
|
||||||
|
for haystack in haystacks:
|
||||||
|
lowered = haystack.lower()
|
||||||
|
if lowered == lowered_query:
|
||||||
|
best_score = max(best_score, 1.0)
|
||||||
|
elif lowered_query in lowered:
|
||||||
|
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
|
||||||
|
if best_score > 0:
|
||||||
|
scored.append({"node": node, "score": round(best_score, 4)})
|
||||||
|
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
for result in raw:
|
||||||
|
result_node = result.get("node", {})
|
||||||
|
node = (
|
||||||
|
self.normalize_node(result_node)
|
||||||
|
if "properties" in result_node or "metadata" in result_node or "content" in result_node
|
||||||
|
else result_node
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_type = filters.get("type") or filters.get("node_type")
|
||||||
|
if filter_type and node["type"] != filter_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
min_confidence = self._coerce_float(filters.get("min_confidence"))
|
||||||
|
node_confidence = self._coerce_float(node["properties"].get("confidence"))
|
||||||
|
if min_confidence is not None and (
|
||||||
|
node_confidence is None or node_confidence < min_confidence
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tags_filter = filters.get("tags")
|
||||||
|
if tags_filter:
|
||||||
|
node_tags = node["properties"].get("tags") or []
|
||||||
|
if isinstance(node_tags, str):
|
||||||
|
node_tags = [node_tags]
|
||||||
|
if not set(tags_filter).issubset(set(node_tags)):
|
||||||
continue
|
continue
|
||||||
node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
|
|
||||||
normalized_results.append(
|
|
||||||
{
|
|
||||||
"node": self.normalize_node(node_payload),
|
|
||||||
"score": score,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
normalized.append({"node": node, "score": result.get("score", 0.0)})
|
||||||
logger.debug(
|
|
||||||
"Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
|
return normalized[:limit]
|
||||||
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]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -593,51 +594,8 @@ class GraphSession:
|
|||||||
|
|
||||||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
added = self.graph.add_nodes(nodes)
|
return 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:
|
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
added = self.graph.add_edges(edges)
|
return 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,18 +328,6 @@ class OWLExporter:
|
|||||||
lines.append("</rdf:RDF>")
|
lines.append("</rdf:RDF>")
|
||||||
return "\n".join(lines)
|
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:
|
def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str:
|
||||||
"""
|
"""
|
||||||
Export ontology to OWL Turtle format.
|
Export ontology to OWL Turtle format.
|
||||||
@@ -354,7 +342,6 @@ class OWLExporter:
|
|||||||
Returns:
|
Returns:
|
||||||
String containing OWL Turtle serialization
|
String containing OWL Turtle serialization
|
||||||
"""
|
"""
|
||||||
esc = self._escape_ttl_str
|
|
||||||
ontology_uri = ontology.get("uri") or self.ontology_uri
|
ontology_uri = ontology.get("uri") or self.ontology_uri
|
||||||
ontology_name = ontology.get("name", "SemanticaOntology")
|
ontology_name = ontology.get("name", "SemanticaOntology")
|
||||||
version = ontology.get("version") or self.version
|
version = ontology.get("version") or self.version
|
||||||
@@ -370,73 +357,63 @@ class OWLExporter:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Ontology declaration
|
# Ontology declaration
|
||||||
onto_predicates = [
|
lines.append(f"<{ontology_uri}> a owl:Ontology ;")
|
||||||
f'rdfs:label "{esc(ontology_name)}"',
|
lines.append(f' rdfs:label "{ontology_name}" ;')
|
||||||
f'owl:versionInfo "{esc(version)}"',
|
lines.append(f' owl:versionInfo "{version}" .')
|
||||||
]
|
if ontology.get("description"):
|
||||||
description = ontology.get("description")
|
lines.append(f' rdfs:comment "{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("")
|
lines.append("")
|
||||||
|
|
||||||
# Classes
|
# Classes
|
||||||
for cls in ontology.get("classes", []):
|
classes = ontology.get("classes", [])
|
||||||
|
for cls in classes:
|
||||||
class_uri = cls.get("uri") or cls.get("id", "")
|
class_uri = cls.get("uri") or cls.get("id", "")
|
||||||
class_name = cls.get("name") or cls.get("label", "")
|
class_name = cls.get("name") or cls.get("label", "")
|
||||||
predicates = [f'rdfs:label "{esc(class_name)}"']
|
|
||||||
comment = cls.get("comment")
|
lines.append(f"<{class_uri}> a owl:Class ;")
|
||||||
if comment:
|
lines.append(f' rdfs:label "{class_name}" .')
|
||||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
|
||||||
sub_class = cls.get("subClassOf")
|
if cls.get("comment"):
|
||||||
if sub_class:
|
lines.append(f' rdfs:comment "{cls.get("comment")}" ;')
|
||||||
predicates.append(f"rdfs:subClassOf <{sub_class}>")
|
|
||||||
equiv = cls.get("equivalentClass")
|
if cls.get("subClassOf"):
|
||||||
if equiv:
|
parent = cls.get("subClassOf")
|
||||||
predicates.append(f"owl:equivalentClass <{equiv}>")
|
lines.append(f" rdfs:subClassOf <{parent}> ;")
|
||||||
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
|
|
||||||
|
# Remove trailing semicolon and add period
|
||||||
|
if lines[-1].endswith(" ;"):
|
||||||
|
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||||
|
else:
|
||||||
|
lines.append(" .")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Object properties
|
# Object properties
|
||||||
for prop in ontology.get("object_properties", []):
|
object_properties = ontology.get("object_properties", [])
|
||||||
|
for prop in object_properties:
|
||||||
prop_uri = prop.get("uri") or prop.get("id", "")
|
prop_uri = prop.get("uri") or prop.get("id", "")
|
||||||
prop_name = prop.get("name") or prop.get("label", "")
|
prop_name = prop.get("name") or prop.get("label", "")
|
||||||
predicates = [f'rdfs:label "{esc(prop_name)}"']
|
|
||||||
comment = prop.get("comment")
|
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
|
||||||
if comment:
|
lines.append(f' rdfs:label "{prop_name}" .')
|
||||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
|
||||||
domain = prop.get("domain")
|
if prop.get("domain"):
|
||||||
if domain:
|
domain = prop.get("domain")
|
||||||
if isinstance(domain, list):
|
if isinstance(domain, list):
|
||||||
for d in domain:
|
for d in domain:
|
||||||
predicates.append(f"rdfs:domain <{d}>")
|
lines.append(f" rdfs:domain <{d}> ;")
|
||||||
else:
|
else:
|
||||||
predicates.append(f"rdfs:domain <{domain}>")
|
lines.append(f" rdfs:domain <{domain}> ;")
|
||||||
range_val = prop.get("range")
|
|
||||||
if range_val:
|
if prop.get("range"):
|
||||||
|
range_val = prop.get("range")
|
||||||
if isinstance(range_val, list):
|
if isinstance(range_val, list):
|
||||||
for r in range_val:
|
for r in range_val:
|
||||||
predicates.append(f"rdfs:range <{r}>")
|
lines.append(f" rdfs:range <{r}> ;")
|
||||||
else:
|
else:
|
||||||
predicates.append(f"rdfs:range <{range_val}>")
|
lines.append(f" rdfs:range <{range_val}> ;")
|
||||||
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
# Data properties
|
if lines[-1].endswith(" ;"):
|
||||||
for prop in ontology.get("data_properties", []):
|
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||||
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("")
|
lines.append("")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -126,14 +126,12 @@ from .temporal_query import (
|
|||||||
TemporalPatternDetector,
|
TemporalPatternDetector,
|
||||||
TemporalVersionManager,
|
TemporalVersionManager,
|
||||||
)
|
)
|
||||||
from .knowledge_graph import KnowledgeGraph
|
|
||||||
from .temporal_model import BiTemporalFact, TemporalBound
|
from .temporal_model import BiTemporalFact, TemporalBound
|
||||||
from .temporal_normalizer import TemporalNormalizer
|
from .temporal_normalizer import TemporalNormalizer
|
||||||
from .temporal_query_rewriter import TemporalQueryRewriter, TemporalQueryResult
|
from .temporal_query_rewriter import TemporalQueryRewriter, TemporalQueryResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core Classes
|
# Core Classes
|
||||||
"KnowledgeGraph",
|
|
||||||
"GraphBuilder",
|
"GraphBuilder",
|
||||||
"GraphBuilderWithProvenance",
|
"GraphBuilderWithProvenance",
|
||||||
"EntityResolver",
|
"EntityResolver",
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""
|
|
||||||
KnowledgeGraph dataclass — canonical in-memory representation.
|
|
||||||
|
|
||||||
This is the formal type produced by the Semantica KG pipeline and consumed
|
|
||||||
by visualizers, exporters, and other downstream components. It is a thin,
|
|
||||||
immutable-friendly wrapper around three plain collections so that isinstance
|
|
||||||
checks, type hints, and IDEs can surface the type rather than relying on
|
|
||||||
bare dicts.
|
|
||||||
|
|
||||||
Keeping this in its own file avoids circular imports between the kg and
|
|
||||||
visualization sub-packages.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class KnowledgeGraph:
|
|
||||||
"""
|
|
||||||
Canonical in-memory knowledge graph.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
entities: List of entity dicts with at minimum ``id`` and ``type`` keys.
|
|
||||||
relationships: List of relationship dicts with at minimum ``source``,
|
|
||||||
``target``, and ``type`` keys.
|
|
||||||
metadata: Arbitrary graph-level metadata (e.g. build timestamps,
|
|
||||||
entity-resolution flags).
|
|
||||||
"""
|
|
||||||
|
|
||||||
entities: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
relationships: List[Dict[str, Any]] = field(default_factory=list)
|
|
||||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Convenience helpers
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
"""Return the number of entities (mirrors the most common 'size' query)."""
|
|
||||||
return len(self.entities)
|
|
||||||
|
|
||||||
def __bool__(self) -> bool:
|
|
||||||
return bool(self.entities or self.relationships)
|
|
||||||
+19
-38
@@ -104,8 +104,7 @@ class PathFinder:
|
|||||||
source: str,
|
source: str,
|
||||||
target: str,
|
target: str,
|
||||||
weight_attribute: str = "weight",
|
weight_attribute: str = "weight",
|
||||||
default_weight: float = 1.0,
|
default_weight: float = 1.0
|
||||||
directed: bool = True
|
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Find shortest path using Dijkstra's algorithm.
|
Find shortest path using Dijkstra's algorithm.
|
||||||
@@ -126,34 +125,32 @@ class PathFinder:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
|
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
|
||||||
|
|
||||||
# Validate nodes exist
|
# Validate nodes exist
|
||||||
if not self._node_exists(graph, source):
|
if not self._node_exists(graph, source):
|
||||||
raise ValueError(f"Source node {source} not found")
|
raise ValueError(f"Source node {source} not found")
|
||||||
if not self._node_exists(graph, target):
|
if not self._node_exists(graph, target):
|
||||||
raise ValueError(f"Target node {target} not found")
|
raise ValueError(f"Target node {target} not found")
|
||||||
|
|
||||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
|
||||||
|
|
||||||
# Dijkstra's algorithm
|
# Dijkstra's algorithm
|
||||||
distances = {source: 0.0}
|
distances = {source: 0.0}
|
||||||
previous = {}
|
previous = {}
|
||||||
priority_queue = [(0.0, source)]
|
priority_queue = [(0.0, source)]
|
||||||
visited = set()
|
visited = set()
|
||||||
|
|
||||||
while priority_queue:
|
while priority_queue:
|
||||||
current_distance, current_node = heapq.heappop(priority_queue)
|
current_distance, current_node = heapq.heappop(priority_queue)
|
||||||
|
|
||||||
if current_node in visited:
|
if current_node in visited:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
visited.add(current_node)
|
visited.add(current_node)
|
||||||
|
|
||||||
if current_node == target:
|
if current_node == target:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Explore neighbors
|
# Explore neighbors
|
||||||
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
|
for neighbor, edge_data in self._get_neighbors(graph, current_node):
|
||||||
if neighbor in visited:
|
if neighbor in visited:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -353,48 +350,44 @@ class PathFinder:
|
|||||||
self,
|
self,
|
||||||
graph: Any,
|
graph: Any,
|
||||||
source: str,
|
source: str,
|
||||||
target: str,
|
target: str
|
||||||
directed: bool = True
|
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Find shortest path using BFS (unweighted).
|
Find shortest path using BFS (unweighted).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
graph: Graph object (NetworkX or similar)
|
graph: Graph object (NetworkX or similar)
|
||||||
source: Source node ID
|
source: Source node ID
|
||||||
target: Target node ID
|
target: Target node ID
|
||||||
directed: If False, treat the graph as undirected for traversal
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of node IDs representing the shortest path
|
List of node IDs representing the shortest path
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If source or target not found
|
ValueError: If source or target not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
|
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
|
||||||
|
|
||||||
# Validate nodes exist
|
# Validate nodes exist
|
||||||
if not self._node_exists(graph, source):
|
if not self._node_exists(graph, source):
|
||||||
raise ValueError(f"Source node {source} not found")
|
raise ValueError(f"Source node {source} not found")
|
||||||
if not self._node_exists(graph, target):
|
if not self._node_exists(graph, target):
|
||||||
raise ValueError(f"Target node {target} not found")
|
raise ValueError(f"Target node {target} not found")
|
||||||
|
|
||||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
|
||||||
|
|
||||||
# BFS algorithm
|
# BFS algorithm
|
||||||
queue = deque([(source, [source])])
|
queue = deque([(source, [source])])
|
||||||
visited = {source}
|
visited = {source}
|
||||||
|
|
||||||
while queue:
|
while queue:
|
||||||
current, path = queue.popleft()
|
current, path = queue.popleft()
|
||||||
|
|
||||||
if current == target:
|
if current == target:
|
||||||
self.logger.info(f"Found BFS path of length {len(path)}")
|
self.logger.info(f"Found BFS path of length {len(path)}")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
# Explore neighbors
|
# Explore neighbors
|
||||||
for neighbor, _ in self._get_neighbors(traversal_graph, current):
|
for neighbor, _ in self._get_neighbors(graph, current):
|
||||||
if neighbor not in visited:
|
if neighbor not in visited:
|
||||||
visited.add(neighbor)
|
visited.add(neighbor)
|
||||||
queue.append((neighbor, path + [neighbor]))
|
queue.append((neighbor, path + [neighbor]))
|
||||||
@@ -571,18 +564,6 @@ class PathFinder:
|
|||||||
return False
|
return False
|
||||||
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]]:
|
def _get_neighbors(self, graph: Any, node: str) -> List[Tuple[str, Any]]:
|
||||||
"""Get neighbors of a node with edge data."""
|
"""Get neighbors of a node with edge data."""
|
||||||
neighbors = []
|
neighbors = []
|
||||||
|
|||||||
@@ -397,7 +397,6 @@ class BaseProvider:
|
|||||||
create_kwargs["response_format"] = {"type": "json_object"}
|
create_kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
response = client.chat.completions.create(**create_kwargs)
|
response = client.chat.completions.create(**create_kwargs)
|
||||||
verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
|
|
||||||
if verbose_mode:
|
if verbose_mode:
|
||||||
import sys
|
import sys
|
||||||
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
|
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
|
||||||
@@ -940,22 +939,20 @@ class DeepSeekProvider(BaseProvider):
|
|||||||
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
|
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.api_key = api_key or config.get_api_key("deepseek")
|
self.api_key = api_key or config.get_api_key("deepseek")
|
||||||
self.base_url = "https://api.deepseek.com/v1"
|
|
||||||
self.model = model
|
self.model = model
|
||||||
self.base_url = "https://api.deepseek.com/v1"
|
|
||||||
self.client = None
|
self.client = None
|
||||||
self._init_client()
|
self._init_client()
|
||||||
|
|
||||||
def _init_client(self):
|
def _init_client(self):
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI
|
import deepseek # type: ignore[import-untyped]
|
||||||
|
|
||||||
if self.api_key:
|
if self.api_key:
|
||||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
self.client = deepseek.Client(api_key=self.api_key)
|
||||||
except (ImportError, OSError):
|
except (ImportError, OSError):
|
||||||
self.client = None
|
self.client = None
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
"openai library not installed. Install with: pip install semantica[llm-openai]"
|
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
|
|||||||
+3
-15
@@ -188,22 +188,10 @@ async def serve_spa(full_path: str):
|
|||||||
if full_path.startswith("api/"):
|
if full_path.startswith("api/"):
|
||||||
raise HTTPException(status_code=404, detail="API route not found")
|
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)
|
normalized_path = os.path.normpath(full_path)
|
||||||
if (
|
if (
|
||||||
os.path.isabs(normalized_path)
|
normalized_path in ("", ".")
|
||||||
|
or os.path.isabs(normalized_path)
|
||||||
or normalized_path == ".."
|
or normalized_path == ".."
|
||||||
or normalized_path.startswith(".." + os.sep)
|
or normalized_path.startswith(".." + os.sep)
|
||||||
):
|
):
|
||||||
@@ -212,7 +200,7 @@ async def serve_spa(full_path: str):
|
|||||||
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
||||||
safe_rel_path = normalized_path.lstrip("/\\")
|
safe_rel_path = normalized_path.lstrip("/\\")
|
||||||
rel_parts = Path(safe_rel_path).parts
|
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")
|
raise HTTPException(status_code=400, detail="Invalid path")
|
||||||
|
|
||||||
static_dir_resolved = STATIC_DIR.resolve()
|
static_dir_resolved = STATIC_DIR.resolve()
|
||||||
|
|||||||
@@ -562,25 +562,3 @@ def retry_on_error(
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
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,15 +53,6 @@ except (ImportError, OSError):
|
|||||||
|
|
||||||
from ..utils.exceptions import ProcessingError
|
from ..utils.exceptions import ProcessingError
|
||||||
from ..utils.logging import get_logger
|
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.progress_tracker import get_progress_tracker
|
||||||
from .utils.color_schemes import ColorPalette, ColorScheme
|
from .utils.color_schemes import ColorPalette, ColorScheme
|
||||||
from .utils.export_formats import (
|
from .utils.export_formats import (
|
||||||
@@ -127,45 +118,18 @@ class KGVisualizer:
|
|||||||
"Install with: pip install plotly"
|
"Install with: pip install plotly"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _convert_knowledge_graph(self, kg: Any) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Convert a KnowledgeGraph instance to the internal dict format.
|
|
||||||
|
|
||||||
Non-mutating. Preserves node types, labels, properties, edge types,
|
|
||||||
weights, and direction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kg: A ``KnowledgeGraph`` instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with "entities", "relationships", and "metadata" keys.
|
|
||||||
"""
|
|
||||||
entities = getattr(kg, "entities", None) or []
|
|
||||||
relationships = getattr(kg, "relationships", None) or []
|
|
||||||
metadata = getattr(kg, "metadata", None) or {}
|
|
||||||
return {
|
|
||||||
"entities": list(entities),
|
|
||||||
"relationships": list(relationships),
|
|
||||||
"metadata": dict(metadata),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
|
def _normalize_graph(self, graph: Any) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Normalize graph input to the expected dict format.
|
Normalize graph input to the expected dict format.
|
||||||
|
|
||||||
Accepts:
|
Accepts either:
|
||||||
- A ``KnowledgeGraph`` instance (routed through ``_convert_knowledge_graph``)
|
|
||||||
- A dict with "entities" and "relationships" keys (canonical format)
|
- A dict with "entities" and "relationships" keys (canonical format)
|
||||||
- Any object that exposes .entities and .relationships attributes
|
- Any object that exposes .entities and .relationships attributes
|
||||||
(duck-typed, e.g. custom dataclasses)
|
(e.g. a KnowledgeGraph dataclass returned by GraphBuilder.build())
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with "entities", "relationships", and "metadata" keys.
|
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):
|
if isinstance(graph, dict):
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
@@ -192,7 +156,6 @@ class KGVisualizer:
|
|||||||
node_color_by: str = "type",
|
node_color_by: str = "type",
|
||||||
node_size_by: Optional[str] = None,
|
node_size_by: Optional[str] = None,
|
||||||
hover_data: Optional[List[str]] = None,
|
hover_data: Optional[List[str]] = None,
|
||||||
highlight_path: Optional[List[str]] = None,
|
|
||||||
**options,
|
**options,
|
||||||
) -> Optional[Any]:
|
) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
@@ -214,9 +177,6 @@ class KGVisualizer:
|
|||||||
node_color_by: Property to map to node color (default: "type")
|
node_color_by: Property to map to node color (default: "type")
|
||||||
node_size_by: Property to map to node size (default: fixed)
|
node_size_by: Property to map to node size (default: fixed)
|
||||||
hover_data: List of properties to show in hover tooltip
|
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
|
**options: Additional visualization options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -266,14 +226,13 @@ class KGVisualizer:
|
|||||||
tracking_id, message="Generating visualization..."
|
tracking_id, message="Generating visualization..."
|
||||||
)
|
)
|
||||||
result = self._visualize_network_plotly(
|
result = self._visualize_network_plotly(
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
output,
|
output,
|
||||||
file_path,
|
file_path,
|
||||||
node_color_by=node_color_by,
|
node_color_by=node_color_by,
|
||||||
node_size_by=node_size_by,
|
node_size_by=node_size_by,
|
||||||
hover_data=hover_data,
|
hover_data=hover_data,
|
||||||
highlight_path=highlight_path,
|
|
||||||
**options
|
**options
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -570,21 +529,6 @@ class KGVisualizer:
|
|||||||
|
|
||||||
return edges
|
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(
|
def _visualize_network_plotly(
|
||||||
self,
|
self,
|
||||||
nodes: List[Dict[str, Any]],
|
nodes: List[Dict[str, Any]],
|
||||||
@@ -594,7 +538,6 @@ class KGVisualizer:
|
|||||||
node_color_by: str = "type",
|
node_color_by: str = "type",
|
||||||
node_size_by: Optional[str] = None,
|
node_size_by: Optional[str] = None,
|
||||||
hover_data: Optional[List[str]] = None,
|
hover_data: Optional[List[str]] = None,
|
||||||
highlight_path: Optional[List[str]] = None,
|
|
||||||
**options,
|
**options,
|
||||||
) -> Optional[Any]:
|
) -> Optional[Any]:
|
||||||
"""Create Plotly network visualization."""
|
"""Create Plotly network visualization."""
|
||||||
@@ -697,73 +640,47 @@ class KGVisualizer:
|
|||||||
|
|
||||||
node_text.append(text)
|
node_text.append(text)
|
||||||
|
|
||||||
# Build path edge lookup for highlight_path support
|
# Prepare edge traces
|
||||||
path_edge_set: set = set()
|
edge_x = []
|
||||||
path_distance_band = "direct"
|
edge_y = []
|
||||||
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)
|
# Prepare edge label traces and annotations (for arrows)
|
||||||
edge_label_x = []
|
edge_label_x = []
|
||||||
edge_label_y = []
|
edge_label_y = []
|
||||||
edge_label_text = []
|
edge_label_text = []
|
||||||
annotations = []
|
annotations = []
|
||||||
|
|
||||||
# Limit detailed edge rendering for performance if graph is too large
|
# Limit detailed edge rendering for performance if graph is too large
|
||||||
show_detailed_edges = len(edges) < 500
|
show_detailed_edges = len(edges) < 500
|
||||||
|
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
source_pos = pos.get(edge["source"])
|
source_pos = pos.get(edge["source"])
|
||||||
target_pos = pos.get(edge["target"])
|
target_pos = pos.get(edge["target"])
|
||||||
if source_pos and target_pos:
|
if source_pos and target_pos:
|
||||||
x0, y0 = source_pos
|
x0, y0 = source_pos
|
||||||
x1, y1 = target_pos
|
x1, y1 = target_pos
|
||||||
|
edge_x.extend([x0, x1, None])
|
||||||
is_path_edge = (edge["source"], edge["target"]) in path_edge_set
|
edge_y.extend([y0, y1, None])
|
||||||
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:
|
if show_detailed_edges:
|
||||||
# Calculate midpoint for label
|
# Calculate midpoint for label
|
||||||
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
||||||
|
|
||||||
if edge.get("label"):
|
if edge.get("label"):
|
||||||
edge_label_x.append(mx)
|
edge_label_x.append(mx)
|
||||||
edge_label_y.append(my)
|
edge_label_y.append(my)
|
||||||
edge_label_text.append(edge["label"])
|
edge_label_text.append(edge["label"])
|
||||||
|
|
||||||
# Add arrow annotation
|
# 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(
|
annotations.append(
|
||||||
dict(
|
dict(
|
||||||
ax=x0, ay=y0, axref='x', ayref='y',
|
ax=x0, ay=y0, axref='x', ayref='y',
|
||||||
x=x1, y=y1, xref='x', yref='y',
|
x=x1, y=y1, xref='x', yref='y',
|
||||||
arrowhead=2, arrowsize=1, arrowwidth=1,
|
arrowhead=2, arrowsize=1, arrowwidth=1,
|
||||||
arrowcolor="#888", opacity=0.6,
|
arrowcolor="#888", opacity=0.6,
|
||||||
standoff=15
|
standoff=15 # Distance from target node
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -776,22 +693,9 @@ class KGVisualizer:
|
|||||||
showlegend=False,
|
showlegend=False,
|
||||||
opacity=0.5
|
opacity=0.5
|
||||||
)
|
)
|
||||||
|
|
||||||
traces = [edge_trace]
|
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:
|
if show_detailed_edges and edge_label_text:
|
||||||
edge_label_trace = go.Scatter(
|
edge_label_trace = go.Scatter(
|
||||||
x=edge_label_x,
|
x=edge_label_x,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import networkx as nx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from semantica.context.context_graph import ContextGraph
|
from semantica.context.context_graph import ContextGraph
|
||||||
@@ -36,16 +35,6 @@ def _build_sample_graph() -> ContextGraph:
|
|||||||
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
|
graph.add_node("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("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("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(
|
graph.add_node(
|
||||||
"decision_1",
|
"decision_1",
|
||||||
node_type="decision",
|
node_type="decision",
|
||||||
@@ -254,74 +243,6 @@ class TestSearchAndStats:
|
|||||||
assert payload["total"] >= 1
|
assert payload["total"] >= 1
|
||||||
assert all(item["node"]["type"] == "language" for item in payload["results"])
|
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):
|
def test_stats(self, client):
|
||||||
response = client.get("/api/graph/stats")
|
response = client.get("/api/graph/stats")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -451,7 +372,7 @@ class TestEnrichment:
|
|||||||
|
|
||||||
def test_extract(self, client):
|
def test_extract(self, client):
|
||||||
response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."})
|
response = client.post("/api/enrich/extract", json={"text": "Alice works at Acme Corp."})
|
||||||
assert response.status_code in (200, 422, 503)
|
assert response.status_code in (200, 422)
|
||||||
|
|
||||||
def test_link_prediction(self, client):
|
def test_link_prediction(self, client):
|
||||||
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
|
response = client.post("/api/enrich/links", json={"node_id": "python", "top_n": 5})
|
||||||
@@ -717,177 +638,3 @@ class TestGenericGraphFileLoading:
|
|||||||
assert repeat.status_code == 200
|
assert repeat.status_code == 200
|
||||||
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
|
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
|
||||||
assert repeat_ids == ["edge-alpha", "edge-beta"]
|
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"
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
"""Unit tests for explorer provenance route helpers."""
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from semantica.explorer.routes.provenance import _build_provenance, _render_markdown
|
|
||||||
|
|
||||||
|
|
||||||
def _make_session_with_chain() -> SimpleNamespace:
|
|
||||||
"""Build a minimal session-like object for Source -> Intermediate -> node_id."""
|
|
||||||
nodes = {
|
|
||||||
"Source": SimpleNamespace(node_type="entity", content="Source"),
|
|
||||||
"Intermediate": SimpleNamespace(node_type="entity", content="Intermediate"),
|
|
||||||
"node_id": SimpleNamespace(node_type="entity", content="Target"),
|
|
||||||
}
|
|
||||||
edges = [
|
|
||||||
SimpleNamespace(source_id="Source", target_id="Intermediate", edge_type="related_to"),
|
|
||||||
SimpleNamespace(source_id="Intermediate", target_id="node_id", edge_type="related_to"),
|
|
||||||
]
|
|
||||||
graph = SimpleNamespace(nodes=nodes, edges=edges)
|
|
||||||
return SimpleNamespace(graph=graph)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_provenance_direction_classification_chain():
|
|
||||||
session = _make_session_with_chain()
|
|
||||||
|
|
||||||
data = _build_provenance(session, "node_id")
|
|
||||||
|
|
||||||
node_ids = {node["id"] for node in data["nodes"]}
|
|
||||||
assert "Source" in node_ids
|
|
||||||
assert "Intermediate" in node_ids
|
|
||||||
|
|
||||||
edge_by_pair = {(edge["source"], edge["target"]): edge for edge in data["edges"]}
|
|
||||||
|
|
||||||
assert edge_by_pair[("Intermediate", "node_id")]["direction"] == "upstream"
|
|
||||||
assert edge_by_pair[("Source", "Intermediate")]["direction"] != "downstream"
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_markdown_groups_edges_by_direction():
|
|
||||||
report = {
|
|
||||||
"node_id": "node_id",
|
|
||||||
"label": "Target",
|
|
||||||
"type": "entity",
|
|
||||||
"properties": {},
|
|
||||||
"lineage": {
|
|
||||||
"nodes": [
|
|
||||||
{"id": "Source", "prov_type": "Entity", "label": "Source"},
|
|
||||||
{"id": "Intermediate", "prov_type": "Entity", "label": "Intermediate"},
|
|
||||||
{"id": "node_id", "prov_type": "Entity", "label": "Target"},
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{
|
|
||||||
"id": "Intermediate-node_id",
|
|
||||||
"source": "Intermediate",
|
|
||||||
"target": "node_id",
|
|
||||||
"label": "related_to",
|
|
||||||
"direction": "upstream",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "Source-Intermediate",
|
|
||||||
"source": "Source",
|
|
||||||
"target": "Intermediate",
|
|
||||||
"label": "related_to",
|
|
||||||
"direction": "lateral",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
markdown = _render_markdown(report)
|
|
||||||
|
|
||||||
assert "## Upstream" in markdown
|
|
||||||
assert "## Lateral" in markdown
|
|
||||||
assert "`Intermediate` -[related_to]-> `node_id`" in markdown
|
|
||||||
assert "`Source` -[related_to]-> `Intermediate`" in markdown
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
"""Tests for OWLExporter._export_owl_turtle fixes (issue #478).
|
|
||||||
|
|
||||||
Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended
|
|
||||||
after a closing period).
|
|
||||||
Bug 2: data_properties silently dropped from Turtle output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from semantica.export import OWLExporter
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def exporter():
|
|
||||||
return OWLExporter()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def full_ontology():
|
|
||||||
return {
|
|
||||||
"uri": "http://example.org/onto",
|
|
||||||
"name": "TestOntology",
|
|
||||||
"description": "A test ontology",
|
|
||||||
"classes": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/Person",
|
|
||||||
"name": "Person",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/Employee",
|
|
||||||
"name": "Employee",
|
|
||||||
"comment": "A person who is employed",
|
|
||||||
"subClassOf": "http://example.org/Person",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/Manager",
|
|
||||||
"name": "Manager",
|
|
||||||
"subClassOf": "http://example.org/Employee",
|
|
||||||
"equivalentClass": "http://example.org/Supervisor",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"object_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/worksFor",
|
|
||||||
"name": "worksFor",
|
|
||||||
"domain": "http://example.org/Employee",
|
|
||||||
"range": "http://example.org/Company",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/manages",
|
|
||||||
"name": "manages",
|
|
||||||
"comment": "manages a team",
|
|
||||||
"domain": ["http://example.org/Manager"],
|
|
||||||
"range": ["http://example.org/Employee"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"data_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/hasAge",
|
|
||||||
"name": "hasAge",
|
|
||||||
"domain": "http://example.org/Person",
|
|
||||||
"range": "integer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/hasName",
|
|
||||||
"name": "hasName",
|
|
||||||
"comment": "full name",
|
|
||||||
"domain": "http://example.org/Person",
|
|
||||||
"range": "string",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bug 1 — valid Turtle syntax
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTurtleSyntaxValidity:
|
|
||||||
"""Every subject block must have exactly one closing period at the end."""
|
|
||||||
|
|
||||||
def _blocks(self, turtle: str) -> list[str]:
|
|
||||||
"""Split output into non-empty logical blocks (separated by blank lines)."""
|
|
||||||
return [b.strip() for b in turtle.split("\n\n") if b.strip()]
|
|
||||||
|
|
||||||
def test_no_triple_after_period(self, exporter, full_ontology):
|
|
||||||
"""No predicate line may appear after a line that ends with ' .'."""
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
lines = turtle.splitlines()
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
stripped = line.rstrip()
|
|
||||||
if stripped.endswith(" .") and i + 1 < len(lines):
|
|
||||||
next_line = lines[i + 1].strip()
|
|
||||||
# next non-blank line must not be a predicate continuation
|
|
||||||
if next_line:
|
|
||||||
assert not next_line.startswith("rdfs:"), (
|
|
||||||
f"Predicate continuation after closing '.' at line {i + 1}: "
|
|
||||||
f"{lines[i]!r} → {lines[i + 1]!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_each_subject_block_ends_with_period(self, exporter, full_ontology):
|
|
||||||
"""Every subject block (class / property declaration) ends with exactly one '.'."""
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
blocks = self._blocks(turtle)
|
|
||||||
# skip the @prefix lines block and ontology declaration
|
|
||||||
subject_blocks = [b for b in blocks if b.startswith("<http://")]
|
|
||||||
for block in subject_blocks:
|
|
||||||
assert block.endswith("."), f"Block does not end with '.': {block!r}"
|
|
||||||
# Must not have a bare '.' on an interior line
|
|
||||||
interior_lines = block.splitlines()[:-1]
|
|
||||||
for ln in interior_lines:
|
|
||||||
assert not ln.rstrip().endswith(" ."), (
|
|
||||||
f"Premature closing period inside block: {ln!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_class_with_subclassof_is_valid(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto",
|
|
||||||
"name": "T",
|
|
||||||
"classes": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/Employee",
|
|
||||||
"name": "Employee",
|
|
||||||
"subClassOf": "http://example.org/Person",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"object_properties": [],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
# Must contain both predicates in the same block
|
|
||||||
assert 'rdfs:label "Employee"' in turtle
|
|
||||||
assert "rdfs:subClassOf <http://example.org/Person>" in turtle
|
|
||||||
# The subClassOf line must NOT come after a closing period
|
|
||||||
lines = turtle.splitlines()
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
if "rdfs:subClassOf" in ln:
|
|
||||||
# Search backwards for the closest period-terminated line
|
|
||||||
for prev in reversed(lines[:i]):
|
|
||||||
prev_s = prev.rstrip()
|
|
||||||
if prev_s:
|
|
||||||
assert not prev_s.endswith(" ."), (
|
|
||||||
"rdfs:subClassOf appeared after a closed block"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
def test_object_property_with_domain_range_is_valid(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto",
|
|
||||||
"name": "T",
|
|
||||||
"classes": [],
|
|
||||||
"object_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/worksFor",
|
|
||||||
"name": "worksFor",
|
|
||||||
"domain": "http://example.org/Employee",
|
|
||||||
"range": "http://example.org/Company",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:domain <http://example.org/Employee>" in turtle
|
|
||||||
assert "rdfs:range <http://example.org/Company>" in turtle
|
|
||||||
lines = turtle.splitlines()
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
if "rdfs:domain" in ln or "rdfs:range" in ln:
|
|
||||||
for prev in reversed(lines[:i]):
|
|
||||||
prev_s = prev.rstrip()
|
|
||||||
if prev_s:
|
|
||||||
assert not prev_s.endswith(" ."), (
|
|
||||||
"domain/range appeared after a closed block"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
def test_class_with_comment_subclassof_both_present(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto",
|
|
||||||
"name": "T",
|
|
||||||
"classes": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/X",
|
|
||||||
"name": "X",
|
|
||||||
"comment": "some comment",
|
|
||||||
"subClassOf": "http://example.org/Y",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"object_properties": [],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert 'rdfs:comment "some comment"' in turtle
|
|
||||||
assert "rdfs:subClassOf <http://example.org/Y>" in turtle
|
|
||||||
# block must end with single period
|
|
||||||
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
|
|
||||||
assert block.endswith(".")
|
|
||||||
assert block.count("\n.") == 0 # no bare period-only lines
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Bug 2 — data properties present in Turtle output
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestDataPropertiesInTurtle:
|
|
||||||
|
|
||||||
def test_data_property_declared_as_datatypeproperty(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "owl:DatatypeProperty" in turtle
|
|
||||||
|
|
||||||
def test_data_property_uri_present(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "<http://example.org/hasAge>" in turtle
|
|
||||||
assert "<http://example.org/hasName>" in turtle
|
|
||||||
|
|
||||||
def test_data_property_label(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert 'rdfs:label "hasAge"' in turtle
|
|
||||||
assert 'rdfs:label "hasName"' in turtle
|
|
||||||
|
|
||||||
def test_data_property_domain(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "rdfs:domain <http://example.org/Person>" in turtle
|
|
||||||
|
|
||||||
def test_data_property_range_uses_xsd_prefix(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "rdfs:range xsd:integer" in turtle
|
|
||||||
assert "rdfs:range xsd:string" in turtle
|
|
||||||
|
|
||||||
def test_data_property_comment(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert 'rdfs:comment "full name"' in turtle
|
|
||||||
|
|
||||||
def test_data_properties_not_in_turtle_was_bug(self, exporter):
|
|
||||||
"""Regression: data_properties were silently dropped before the fix."""
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto",
|
|
||||||
"name": "T",
|
|
||||||
"classes": [],
|
|
||||||
"object_properties": [],
|
|
||||||
"data_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/birthDate",
|
|
||||||
"name": "birthDate",
|
|
||||||
"range": "date",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "owl:DatatypeProperty" in turtle, (
|
|
||||||
"Data properties must appear in Turtle output (was silently dropped)"
|
|
||||||
)
|
|
||||||
assert "<http://example.org/birthDate>" in turtle
|
|
||||||
assert "rdfs:range xsd:date" in turtle
|
|
||||||
|
|
||||||
def test_data_property_block_ends_with_period(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
blocks = [b.strip() for b in turtle.split("\n\n") if "owl:DatatypeProperty" in b]
|
|
||||||
assert blocks, "Expected at least one DatatypeProperty block"
|
|
||||||
for block in blocks:
|
|
||||||
assert block.endswith("."), f"DatatypeProperty block missing closing '.': {block!r}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Namespace and ontology header
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTurtleHeader:
|
|
||||||
|
|
||||||
def test_prefix_declarations(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "@prefix rdf:" in turtle
|
|
||||||
assert "@prefix rdfs:" in turtle
|
|
||||||
assert "@prefix owl:" in turtle
|
|
||||||
assert "@prefix xsd:" in turtle
|
|
||||||
|
|
||||||
def test_ontology_declaration(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert "a owl:Ontology" in turtle
|
|
||||||
assert 'rdfs:label "TestOntology"' in turtle
|
|
||||||
assert 'owl:versionInfo "1.0"' in turtle
|
|
||||||
|
|
||||||
def test_ontology_description_included(self, exporter, full_ontology):
|
|
||||||
turtle = exporter._export_owl_turtle(full_ontology)
|
|
||||||
assert 'rdfs:comment "A test ontology"' in turtle
|
|
||||||
|
|
||||||
def test_ontology_without_description(self, exporter):
|
|
||||||
ontology = {"uri": "http://example.org/onto", "name": "NoDesc",
|
|
||||||
"classes": [], "object_properties": [], "data_properties": []}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:comment" not in turtle
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Object properties — list domain/range
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestObjectPropertyListDomainRange:
|
|
||||||
|
|
||||||
def test_list_domain(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto", "name": "T",
|
|
||||||
"classes": [],
|
|
||||||
"object_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/p",
|
|
||||||
"name": "p",
|
|
||||||
"domain": ["http://example.org/A", "http://example.org/B"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:domain <http://example.org/A>" in turtle
|
|
||||||
assert "rdfs:domain <http://example.org/B>" in turtle
|
|
||||||
|
|
||||||
def test_list_range(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto", "name": "T",
|
|
||||||
"classes": [],
|
|
||||||
"object_properties": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/p",
|
|
||||||
"name": "p",
|
|
||||||
"range": ["http://example.org/X", "http://example.org/Y"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:range <http://example.org/X>" in turtle
|
|
||||||
assert "rdfs:range <http://example.org/Y>" in turtle
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# equivalentClass support (also tested under Bug 1 guard)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestEquivalentClass:
|
|
||||||
|
|
||||||
def test_equivalent_class_in_turtle(self, exporter):
|
|
||||||
ontology = {
|
|
||||||
"uri": "http://example.org/onto", "name": "T",
|
|
||||||
"classes": [
|
|
||||||
{
|
|
||||||
"uri": "http://example.org/Manager",
|
|
||||||
"name": "Manager",
|
|
||||||
"equivalentClass": "http://example.org/Supervisor",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"object_properties": [],
|
|
||||||
"data_properties": [],
|
|
||||||
}
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "owl:equivalentClass <http://example.org/Supervisor>" in turtle
|
|
||||||
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
|
|
||||||
assert block.endswith(".")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# String escaping in Turtle literals (issue #478 review — escape_001)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestTurtleStringEscaping:
|
|
||||||
"""User-provided strings must be escaped before embedding in Turtle literals."""
|
|
||||||
|
|
||||||
def _onto(self, **kwargs):
|
|
||||||
base = {"uri": "http://example.org/onto", "name": "T",
|
|
||||||
"classes": [], "object_properties": [], "data_properties": []}
|
|
||||||
base.update(kwargs)
|
|
||||||
return base
|
|
||||||
|
|
||||||
def test_escape_ttl_str_double_quote(self, exporter):
|
|
||||||
assert exporter._escape_ttl_str('say "hello"') == r'say \"hello\"'
|
|
||||||
|
|
||||||
def test_escape_ttl_str_backslash(self, exporter):
|
|
||||||
assert exporter._escape_ttl_str("C:\\path") == "C:\\\\path"
|
|
||||||
|
|
||||||
def test_escape_ttl_str_newline(self, exporter):
|
|
||||||
assert exporter._escape_ttl_str("line1\nline2") == "line1\\nline2"
|
|
||||||
|
|
||||||
def test_escape_ttl_str_carriage_return(self, exporter):
|
|
||||||
assert exporter._escape_ttl_str("a\rb") == "a\\rb"
|
|
||||||
|
|
||||||
def test_escape_ttl_str_tab(self, exporter):
|
|
||||||
assert exporter._escape_ttl_str("col1\tcol2") == "col1\\tcol2"
|
|
||||||
|
|
||||||
def test_escape_ttl_str_combined(self, exporter):
|
|
||||||
raw = 'back\\slash and "quote"\nnewline'
|
|
||||||
escaped = exporter._escape_ttl_str(raw)
|
|
||||||
assert '\\"' in escaped
|
|
||||||
assert "\\\\" in escaped
|
|
||||||
assert "\\n" in escaped
|
|
||||||
|
|
||||||
def test_ontology_name_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(name='John"s Ontology')
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert 'rdfs:label "John\\"s Ontology"' in turtle
|
|
||||||
assert 'rdfs:label "John"s Ontology"' not in turtle
|
|
||||||
|
|
||||||
def test_ontology_description_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(description='Describes "things"')
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert 'rdfs:comment "Describes \\"things\\""' in turtle
|
|
||||||
|
|
||||||
def test_class_name_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(classes=[{
|
|
||||||
"uri": "http://example.org/C",
|
|
||||||
"name": 'My "Special" Class',
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:label "My \"Special\" Class"' in turtle
|
|
||||||
|
|
||||||
def test_class_comment_with_backslash_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(classes=[{
|
|
||||||
"uri": "http://example.org/C",
|
|
||||||
"name": "C",
|
|
||||||
"comment": "path is C:\\Users",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:comment "path is C:\\Users"' in turtle
|
|
||||||
|
|
||||||
def test_class_comment_with_newline_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(classes=[{
|
|
||||||
"uri": "http://example.org/C",
|
|
||||||
"name": "C",
|
|
||||||
"comment": "line1\nline2",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:comment "line1\nline2"' in turtle
|
|
||||||
|
|
||||||
def test_object_property_name_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(object_properties=[{
|
|
||||||
"uri": "http://example.org/p",
|
|
||||||
"name": 'has"Value',
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:label "has\"Value"' in turtle
|
|
||||||
|
|
||||||
def test_object_property_comment_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(object_properties=[{
|
|
||||||
"uri": "http://example.org/p",
|
|
||||||
"name": "p",
|
|
||||||
"comment": 'links "A" to "B"',
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:comment "links \"A\" to \"B\""' in turtle
|
|
||||||
|
|
||||||
def test_data_property_name_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(data_properties=[{
|
|
||||||
"uri": "http://example.org/dp",
|
|
||||||
"name": 'the "name" prop',
|
|
||||||
"range": "string",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:label "the \"name\" prop"' in turtle
|
|
||||||
|
|
||||||
def test_data_property_comment_with_quote_is_escaped(self, exporter):
|
|
||||||
ontology = self._onto(data_properties=[{
|
|
||||||
"uri": "http://example.org/dp",
|
|
||||||
"name": "dp",
|
|
||||||
"comment": 'see "spec" §3',
|
|
||||||
"range": "string",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert r'rdfs:comment "see \"spec\" §3"' in turtle
|
|
||||||
|
|
||||||
def test_plain_strings_unchanged(self, exporter):
|
|
||||||
"""Strings without special chars must pass through unchanged."""
|
|
||||||
ontology = self._onto(
|
|
||||||
name="MyOntology",
|
|
||||||
classes=[{"uri": "http://example.org/C", "name": "SafeName"}],
|
|
||||||
)
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert 'rdfs:label "MyOntology"' in turtle
|
|
||||||
assert 'rdfs:label "SafeName"' in turtle
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Null / missing optional fields — no KeyError raised (review null_check_001-3)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestNullFieldHandling:
|
|
||||||
"""Optional fields absent from dicts must not raise KeyError."""
|
|
||||||
|
|
||||||
def _onto(self, **kwargs):
|
|
||||||
base = {"uri": "http://example.org/onto", "name": "T",
|
|
||||||
"classes": [], "object_properties": [], "data_properties": []}
|
|
||||||
base.update(kwargs)
|
|
||||||
return base
|
|
||||||
|
|
||||||
def test_class_no_optional_fields(self, exporter):
|
|
||||||
ontology = self._onto(classes=[{"uri": "http://example.org/C", "name": "C"}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "owl:Class" in turtle
|
|
||||||
|
|
||||||
def test_object_property_no_domain_no_range(self, exporter):
|
|
||||||
ontology = self._onto(object_properties=[{
|
|
||||||
"uri": "http://example.org/p", "name": "p"
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "owl:ObjectProperty" in turtle
|
|
||||||
assert "rdfs:domain" not in turtle
|
|
||||||
assert "rdfs:range" not in turtle
|
|
||||||
|
|
||||||
def test_data_property_no_domain_no_range(self, exporter):
|
|
||||||
ontology = self._onto(data_properties=[{
|
|
||||||
"uri": "http://example.org/dp", "name": "dp"
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "owl:DatatypeProperty" in turtle
|
|
||||||
assert "rdfs:domain" not in turtle
|
|
||||||
assert "rdfs:range" not in turtle
|
|
||||||
|
|
||||||
def test_data_property_none_domain(self, exporter):
|
|
||||||
"""Explicit None value for domain must not raise KeyError."""
|
|
||||||
ontology = self._onto(data_properties=[{
|
|
||||||
"uri": "http://example.org/dp", "name": "dp",
|
|
||||||
"domain": None, "range": "string",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:domain" not in turtle
|
|
||||||
|
|
||||||
def test_data_property_none_range(self, exporter):
|
|
||||||
"""Explicit None value for range must not raise KeyError."""
|
|
||||||
ontology = self._onto(data_properties=[{
|
|
||||||
"uri": "http://example.org/dp", "name": "dp",
|
|
||||||
"domain": "http://example.org/C", "range": None,
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:range" not in turtle
|
|
||||||
|
|
||||||
def test_object_property_none_domain(self, exporter):
|
|
||||||
ontology = self._onto(object_properties=[{
|
|
||||||
"uri": "http://example.org/p", "name": "p",
|
|
||||||
"domain": None, "range": "http://example.org/X",
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:domain" not in turtle
|
|
||||||
|
|
||||||
def test_object_property_none_range(self, exporter):
|
|
||||||
ontology = self._onto(object_properties=[{
|
|
||||||
"uri": "http://example.org/p", "name": "p",
|
|
||||||
"domain": "http://example.org/A", "range": None,
|
|
||||||
}])
|
|
||||||
turtle = exporter._export_owl_turtle(ontology)
|
|
||||||
assert "rdfs:range" not in turtle
|
|
||||||
@@ -821,87 +821,3 @@ class TestPathFinderEdgeCases:
|
|||||||
|
|
||||||
paths = self.finder.all_shortest_paths(single_node_graph, "A")
|
paths = self.finder.all_shortest_paths(single_node_graph, "A")
|
||||||
assert len(paths) == 0 # No paths to other nodes
|
assert len(paths) == 0 # No paths to other nodes
|
||||||
|
|
||||||
|
|
||||||
class TestBidirectionalPathFinding:
|
|
||||||
"""Tests for the directed=False undirected-traversal mode (issue #469)."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
self.finder = PathFinder()
|
|
||||||
# Single directed edge A → B. Reverse query B → A has no directed path.
|
|
||||||
self.digraph = nx.DiGraph()
|
|
||||||
self.digraph.add_edge("A", "B")
|
|
||||||
|
|
||||||
# --- directed=True (default) preserves existing behaviour ---
|
|
||||||
|
|
||||||
def test_bfs_directed_true_reverse_returns_empty(self):
|
|
||||||
"""B→A should find nothing when directed=True (default)."""
|
|
||||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=True)
|
|
||||||
assert path == []
|
|
||||||
|
|
||||||
def test_dijkstra_directed_true_reverse_returns_empty(self):
|
|
||||||
"""B→A should find nothing when directed=True (default)."""
|
|
||||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=True)
|
|
||||||
assert path == []
|
|
||||||
|
|
||||||
def test_bfs_directed_true_default_arg(self):
|
|
||||||
"""Omitting directed= should behave the same as directed=True."""
|
|
||||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A")
|
|
||||||
assert path == []
|
|
||||||
|
|
||||||
def test_dijkstra_directed_true_default_arg(self):
|
|
||||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A")
|
|
||||||
assert path == []
|
|
||||||
|
|
||||||
# --- directed=False finds path against edge orientation ---
|
|
||||||
|
|
||||||
def test_bfs_directed_false_reverse_single_edge(self):
|
|
||||||
"""directed=False must find B→A even though only A→B exists."""
|
|
||||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=False)
|
|
||||||
assert path == ["B", "A"]
|
|
||||||
|
|
||||||
def test_dijkstra_directed_false_reverse_single_edge(self):
|
|
||||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=False)
|
|
||||||
assert path == ["B", "A"]
|
|
||||||
|
|
||||||
def test_bfs_directed_false_forward_still_works(self):
|
|
||||||
"""directed=False should not break the forward direction."""
|
|
||||||
path = self.finder.bfs_shortest_path(self.digraph, "A", "B", directed=False)
|
|
||||||
assert path == ["A", "B"]
|
|
||||||
|
|
||||||
def test_dijkstra_directed_false_forward_still_works(self):
|
|
||||||
path = self.finder.dijkstra_shortest_path(self.digraph, "A", "B", directed=False)
|
|
||||||
assert path == ["A", "B"]
|
|
||||||
|
|
||||||
# --- multi-hop path where one edge is against the query direction ---
|
|
||||||
|
|
||||||
def test_bfs_directed_false_multihop(self):
|
|
||||||
"""A→B, C→B graph: directed=False lets us find A→B→C (i.e. A→C via B)."""
|
|
||||||
g = nx.DiGraph()
|
|
||||||
g.add_edge("A", "B")
|
|
||||||
g.add_edge("C", "B") # oriented towards B, not away from it
|
|
||||||
# undirected view: A-B-C, so A→C path exists
|
|
||||||
path = self.finder.bfs_shortest_path(g, "A", "C", directed=False)
|
|
||||||
assert path[0] == "A" and path[-1] == "C"
|
|
||||||
assert "B" in path
|
|
||||||
|
|
||||||
def test_dijkstra_directed_false_multihop(self):
|
|
||||||
g = nx.DiGraph()
|
|
||||||
g.add_edge("A", "B")
|
|
||||||
g.add_edge("C", "B")
|
|
||||||
path = self.finder.dijkstra_shortest_path(g, "A", "C", directed=False)
|
|
||||||
assert path[0] == "A" and path[-1] == "C"
|
|
||||||
assert "B" in path
|
|
||||||
|
|
||||||
# --- PathResponse.directed field ---
|
|
||||||
|
|
||||||
def test_path_response_directed_field_exists(self):
|
|
||||||
"""PathResponse must carry a directed field."""
|
|
||||||
from semantica.explorer.schemas import PathResponse
|
|
||||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"], directed=False)
|
|
||||||
assert r.directed is False
|
|
||||||
|
|
||||||
def test_path_response_directed_field_defaults_true(self):
|
|
||||||
from semantica.explorer.schemas import PathResponse
|
|
||||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"])
|
|
||||||
assert r.directed is True
|
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch, MagicMock, call
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeepSeekProviderInit(unittest.TestCase):
|
|
||||||
"""Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
|
||||||
self.DeepSeekProvider = DeepSeekProvider
|
|
||||||
|
|
||||||
def test_base_url_set_on_init(self):
|
|
||||||
"""self.base_url must be set before _init_client is called (PR #482 regression)."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
|
||||||
self.assertTrue(
|
|
||||||
hasattr(provider, "base_url"),
|
|
||||||
"DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
|
|
||||||
)
|
|
||||||
self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
|
|
||||||
|
|
||||||
def test_base_url_points_to_v1_endpoint(self):
|
|
||||||
"""base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
|
||||||
self.assertIn("/v1", provider.base_url, "base_url must include /v1")
|
|
||||||
|
|
||||||
def test_init_client_uses_openai_not_deepseek(self):
|
|
||||||
"""_init_client must import openai.OpenAI, not deepseek.Client."""
|
|
||||||
mock_openai_cls = MagicMock()
|
|
||||||
mock_openai_instance = MagicMock()
|
|
||||||
mock_openai_cls.return_value = mock_openai_instance
|
|
||||||
|
|
||||||
with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
|
|
||||||
# Re-import to pick up patched sys.modules
|
|
||||||
import importlib
|
|
||||||
import semantica.semantic_extract.providers as providers_mod
|
|
||||||
importlib.reload(providers_mod)
|
|
||||||
DeepSeekProvider = providers_mod.DeepSeekProvider
|
|
||||||
|
|
||||||
provider = DeepSeekProvider(api_key="sk-test")
|
|
||||||
|
|
||||||
mock_openai_cls.assert_called_once_with(
|
|
||||||
api_key="sk-test",
|
|
||||||
base_url="https://api.deepseek.com/v1",
|
|
||||||
)
|
|
||||||
self.assertIs(provider.client, mock_openai_instance)
|
|
||||||
|
|
||||||
def test_init_client_no_api_key_leaves_client_none(self):
|
|
||||||
"""Without an API key, client must remain None."""
|
|
||||||
with patch("semantica.semantic_extract.providers.config") as mock_cfg:
|
|
||||||
mock_cfg.get_api_key.return_value = None
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key=None)
|
|
||||||
provider.client = None # simulate _init_client no-op
|
|
||||||
self.assertFalse(provider.is_available())
|
|
||||||
|
|
||||||
def test_init_client_handles_openai_import_error(self):
|
|
||||||
"""If openai is not installed, _init_client must set client=None, not raise."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = None # manually simulate ImportError path
|
|
||||||
# Directly call _init_client with openai blocked
|
|
||||||
with patch.dict("sys.modules", {"openai": None}):
|
|
||||||
try:
|
|
||||||
provider._init_client()
|
|
||||||
except Exception as e:
|
|
||||||
self.fail(f"_init_client raised unexpectedly: {e}")
|
|
||||||
self.assertIsNone(provider.client)
|
|
||||||
|
|
||||||
def test_is_available_true_when_client_set(self):
|
|
||||||
"""is_available() returns True when self.client is an OpenAI instance."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = MagicMock()
|
|
||||||
self.assertTrue(provider.is_available())
|
|
||||||
|
|
||||||
def test_is_available_false_when_client_none(self):
|
|
||||||
"""is_available() returns False when self.client is None."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = None
|
|
||||||
self.assertFalse(provider.is_available())
|
|
||||||
|
|
||||||
def test_no_deepseek_module_imported(self):
|
|
||||||
"""deepseek module must NOT be imported by _init_client after PR #482."""
|
|
||||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = None
|
|
||||||
|
|
||||||
blocked = MagicMock()
|
|
||||||
blocked.__spec__ = None
|
|
||||||
with patch.dict("sys.modules", {"deepseek": None}):
|
|
||||||
# _init_client should succeed even if deepseek is completely absent
|
|
||||||
mock_openai = MagicMock()
|
|
||||||
mock_openai.OpenAI.return_value = MagicMock()
|
|
||||||
with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
|
|
||||||
try:
|
|
||||||
provider._init_client()
|
|
||||||
except Exception as e:
|
|
||||||
self.fail(f"_init_client raised when deepseek absent: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeepSeekProviderGenerate(unittest.TestCase):
|
|
||||||
"""Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
|
|
||||||
|
|
||||||
def _make_provider(self, api_key="sk-test"):
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key=api_key)
|
|
||||||
provider.client = MagicMock()
|
|
||||||
return provider
|
|
||||||
|
|
||||||
def test_generate_uses_chat_completions(self):
|
|
||||||
"""generate() must call client.chat.completions.create."""
|
|
||||||
provider = self._make_provider()
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.choices[0].message.content = "hello"
|
|
||||||
provider.client.chat.completions.create.return_value = mock_resp
|
|
||||||
|
|
||||||
result = provider.generate("test prompt")
|
|
||||||
|
|
||||||
provider.client.chat.completions.create.assert_called_once()
|
|
||||||
self.assertEqual(result, "hello")
|
|
||||||
|
|
||||||
def test_generate_passes_model(self):
|
|
||||||
provider = self._make_provider()
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.choices[0].message.content = "x"
|
|
||||||
provider.client.chat.completions.create.return_value = mock_resp
|
|
||||||
|
|
||||||
provider.generate("p", model="deepseek-reasoner")
|
|
||||||
kwargs = provider.client.chat.completions.create.call_args[1]
|
|
||||||
self.assertEqual(kwargs["model"], "deepseek-reasoner")
|
|
||||||
|
|
||||||
def test_generate_structured_returns_parsed_json(self):
|
|
||||||
provider = self._make_provider()
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.choices[0].message.content = '{"key": "value"}'
|
|
||||||
provider.client.chat.completions.create.return_value = mock_resp
|
|
||||||
|
|
||||||
result = provider.generate_structured("test prompt")
|
|
||||||
self.assertEqual(result, {"key": "value"})
|
|
||||||
|
|
||||||
def test_generate_raises_without_client(self):
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = None
|
|
||||||
|
|
||||||
with self.assertRaises(ProcessingError):
|
|
||||||
provider.generate("prompt")
|
|
||||||
|
|
||||||
def test_generate_structured_raises_without_client(self):
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = None
|
|
||||||
|
|
||||||
with self.assertRaises(ProcessingError):
|
|
||||||
provider.generate_structured("prompt")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeepSeekInstructorPath(unittest.TestCase):
|
|
||||||
"""Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
|
|
||||||
|
|
||||||
def _make_provider(self, api_key="sk-test"):
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
from openai import OpenAI
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key=api_key)
|
|
||||||
# After PR #482, client is an OpenAI instance
|
|
||||||
mock_client = MagicMock(spec=OpenAI)
|
|
||||||
provider.client = mock_client
|
|
||||||
return provider
|
|
||||||
|
|
||||||
def test_generate_typed_instructor_openai_isinstance_check(self):
|
|
||||||
"""After PR #482, client is OpenAI, so instructor path must use from_openai."""
|
|
||||||
from openai import OpenAI
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key="sk-test")
|
|
||||||
provider.client = MagicMock(spec=OpenAI)
|
|
||||||
|
|
||||||
self.assertIsInstance(
|
|
||||||
provider.client, OpenAI,
|
|
||||||
"client must be OpenAI instance for instructor isinstance check to pass",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerboseModeAssignment(unittest.TestCase):
|
|
||||||
"""Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
|
|
||||||
|
|
||||||
def _make_openai_provider(self):
|
|
||||||
from semantica.semantic_extract.providers import OpenAIProvider
|
|
||||||
with patch.object(OpenAIProvider, "_init_client", return_value=None):
|
|
||||||
provider = OpenAIProvider(api_key="sk-test")
|
|
||||||
provider.client = MagicMock()
|
|
||||||
return provider
|
|
||||||
|
|
||||||
def test_generate_typed_no_verbose_no_name_error(self):
|
|
||||||
"""generate_typed must not raise NameError for verbose_mode when verbose not passed."""
|
|
||||||
provider = self._make_openai_provider()
|
|
||||||
|
|
||||||
class Schema(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
mock_instructor = MagicMock()
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.chat.completions.create.return_value = Schema(value="ok")
|
|
||||||
mock_instructor.from_openai.return_value = mock_client
|
|
||||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
|
||||||
mock_instructor.Mode.TOOLS = "tools"
|
|
||||||
|
|
||||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
|
||||||
try:
|
|
||||||
result = provider.generate_typed("prompt", Schema)
|
|
||||||
except NameError as e:
|
|
||||||
self.fail(f"NameError for verbose_mode: {e}")
|
|
||||||
except Exception:
|
|
||||||
pass # other errors are OK — we only care NameError is gone
|
|
||||||
|
|
||||||
def test_generate_typed_verbose_true_prints(self):
|
|
||||||
"""When verbose=True, generate_typed must print the confirmation line."""
|
|
||||||
provider = self._make_openai_provider()
|
|
||||||
|
|
||||||
class Schema(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
mock_schema_instance = Schema(value="ok")
|
|
||||||
mock_instructor = MagicMock()
|
|
||||||
mock_ic_client = MagicMock()
|
|
||||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
|
||||||
mock_instructor.from_openai.return_value = mock_ic_client
|
|
||||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
|
||||||
mock_instructor.Mode.TOOLS = "tools"
|
|
||||||
|
|
||||||
import io
|
|
||||||
captured = io.StringIO()
|
|
||||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
|
||||||
with patch("sys.stdout", captured):
|
|
||||||
try:
|
|
||||||
provider.generate_typed("prompt", Schema, verbose=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
output = captured.getvalue()
|
|
||||||
# verbose_mode=True should trigger the print statement
|
|
||||||
self.assertIn("generate_typed", output)
|
|
||||||
|
|
||||||
def test_generate_typed_verbose_false_no_print(self):
|
|
||||||
"""When verbose=False (default), generate_typed must not print anything."""
|
|
||||||
provider = self._make_openai_provider()
|
|
||||||
|
|
||||||
class Schema(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
mock_schema_instance = Schema(value="ok")
|
|
||||||
mock_instructor = MagicMock()
|
|
||||||
mock_ic_client = MagicMock()
|
|
||||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
|
||||||
mock_instructor.from_openai.return_value = mock_ic_client
|
|
||||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
|
||||||
mock_instructor.Mode.TOOLS = "tools"
|
|
||||||
|
|
||||||
import io
|
|
||||||
captured = io.StringIO()
|
|
||||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
|
||||||
with patch("sys.stdout", captured):
|
|
||||||
try:
|
|
||||||
provider.generate_typed("prompt", Schema)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.assertEqual(captured.getvalue(), "")
|
|
||||||
|
|
||||||
def test_generate_typed_verbose_from_config(self):
|
|
||||||
"""verbose_mode must also respect config-level verbose setting."""
|
|
||||||
provider = self._make_openai_provider()
|
|
||||||
provider.config["verbose"] = True
|
|
||||||
|
|
||||||
class Schema(BaseModel):
|
|
||||||
value: str
|
|
||||||
|
|
||||||
mock_schema_instance = Schema(value="ok")
|
|
||||||
mock_instructor = MagicMock()
|
|
||||||
mock_ic_client = MagicMock()
|
|
||||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
|
||||||
mock_instructor.from_openai.return_value = mock_ic_client
|
|
||||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
|
||||||
mock_instructor.Mode.TOOLS = "tools"
|
|
||||||
|
|
||||||
import io
|
|
||||||
captured = io.StringIO()
|
|
||||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
|
||||||
with patch("sys.stdout", captured):
|
|
||||||
try:
|
|
||||||
provider.generate_typed("prompt", Schema)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.assertIn("generate_typed", captured.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
|
|
||||||
"""Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
|
|
||||||
|
|
||||||
def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
|
|
||||||
"""generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
|
|
||||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
|
||||||
provider = DeepSeekProvider(api_key="sk-test")
|
|
||||||
mock_openai_client = MagicMock(spec=OpenAI)
|
|
||||||
provider.client = mock_openai_client
|
|
||||||
|
|
||||||
class Schema(BaseModel):
|
|
||||||
label: str
|
|
||||||
|
|
||||||
mock_instructor = MagicMock()
|
|
||||||
mock_ic_client = MagicMock()
|
|
||||||
mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
|
|
||||||
mock_instructor.from_openai.return_value = mock_ic_client
|
|
||||||
mock_instructor.from_provider.side_effect = Exception("no from_provider")
|
|
||||||
mock_instructor.Mode.JSON = "json"
|
|
||||||
mock_instructor.Mode.TOOLS = "tools"
|
|
||||||
|
|
||||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
|
||||||
result = provider.generate_typed("classify this", Schema)
|
|
||||||
|
|
||||||
# Must have called from_openai with the existing client (not a fresh one)
|
|
||||||
mock_instructor.from_openai.assert_called_once_with(
|
|
||||||
mock_openai_client, mode="json"
|
|
||||||
)
|
|
||||||
self.assertEqual(result.label, "ok")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -283,166 +283,5 @@ class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
|
|||||||
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user