Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffed78fd9 | ||
|
|
f06de0dab2 | ||
|
|
dd016744ce | ||
|
|
7884d71e23 | ||
|
|
ca5f081793 | ||
|
|
6ad1502224 | ||
|
|
b010ba68fa | ||
|
|
7c8dfbd3c0 | ||
|
|
45400c88d3 | ||
|
|
26f6cdf9e5 | ||
|
|
96f88594c2 | ||
|
|
c0d08c46f7 | ||
|
|
5a388d0bcc | ||
|
|
f516aef8fd | ||
|
|
c9a382e676 | ||
|
|
897d950bdc | ||
|
|
dd8fa17db8 | ||
|
|
92801c220e | ||
|
|
738480606c | ||
|
|
f9e0bcf210 | ||
|
|
bb0e9f49e3 | ||
|
|
f95c1612d5 | ||
|
|
8c202a691e | ||
|
|
304b82fbd6 | ||
|
|
8d2dfaa53c | ||
|
|
39aaae778f | ||
|
|
16d628997a | ||
|
|
5e6ad6e87e | ||
|
|
f6198039fa | ||
|
|
983f5301e8 | ||
|
|
5a852169be | ||
|
|
fe6ca7fccb | ||
|
|
66c8431eee | ||
|
|
3e2a0a3f3b | ||
|
|
d22a54353a | ||
|
|
f165679c11 | ||
|
|
17460edca9 | ||
|
|
7e815920ac | ||
|
|
7f93eb7104 | ||
|
|
eec3e8804a | ||
|
|
9cb6073568 | ||
|
|
073c48882c | ||
|
|
be86d1b5db | ||
|
|
6f93f429c4 | ||
|
|
bc683e7a34 | ||
|
|
cda5310949 | ||
|
|
17f88ca600 | ||
|
|
658de23357 | ||
|
|
66e8964d22 | ||
|
|
892ff4b4a7 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 | ||
|
|
2ce5067aa3 | ||
|
|
d056e47ab7 | ||
|
|
8eafd2d024 | ||
|
|
7ba93f6772 | ||
|
|
d466203761 | ||
|
|
730dea7911 | ||
|
|
47764c3033 | ||
|
|
055d2fd98d | ||
|
|
ce66681715 | ||
|
|
655b553262 | ||
|
|
60bf8ec75e | ||
|
|
a1478af9c4 | ||
|
|
ee4d6a9188 | ||
|
|
2d00257ae5 | ||
|
|
1cc2f6b93a | ||
|
|
9e26d96b3c | ||
|
|
7267425eb5 | ||
|
|
d9cf7b0088 | ||
|
|
09666806da | ||
|
|
9daddd8186 | ||
|
|
dc8d7ddb03 | ||
|
|
b34634c8b5 | ||
|
|
ee93c4bbe1 | ||
|
|
e4425818e4 | ||
|
|
7b31304e1e | ||
|
|
ab93ec3e8f | ||
|
|
f2eb3e1608 | ||
|
|
898a660ca7 | ||
|
|
fb1e6a6d6e | ||
|
|
0d7b9ca1df | ||
|
|
aca15d3694 | ||
|
|
670027fd22 | ||
|
|
3ea1283626 |
@@ -0,0 +1 @@
|
|||||||
|
# Initialization
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Intialization
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Initialization
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: ./site
|
path: ./site
|
||||||
|
|
||||||
|
|||||||
@@ -111,5 +111,12 @@ 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,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **Feature: Distance Intelligence** (closes #502 by @KaifAhmad1):
|
||||||
|
- **Context layer** — `ContextGraph.get_neighbors()` gains `include_distance_metadata=False`; when enabled adds `distance_band`, `confidence_decay`, and `path_to_anchor` per result. New `get_neighbor_distances()` returns neighbors sorted by `(hop, -decay)` with optional `min_confidence` filter. `AgentContext.retrieve()` / `find_precedents()` accept `anchor_node`, `max_hops`, `proximity_weight`, `min_confidence_decay` and blend graph proximity with semantic score as `combined_score = (1 − w) × semantic + w × proximity`.
|
||||||
|
- **Path enrichment (FR-4)** — `GET /api/graph/node/{id}/path` now returns `semantic_similarity`, `path_coherence_score`, `confidence_decay` (O(L) via pre-built edge-weight index), `bottleneck_node`, `alternative_path_count`, and `interpretation`. All fields optional; zero breaking changes.
|
||||||
|
- **Distance matrix (FR-6)** — `POST /api/graph/distance-matrix` accepts up to 50 nodes and metric `hops | weighted | semantic`. Returns N × N matrix (upper-triangle computed, lower mirrored), unreachable pairs, and `computation_time_ms`.
|
||||||
|
- **Semantic neighborhood (FR-3 backend)** — `GET /api/graph/node/{id}/semantic-neighborhood?top_k=N` returns the N most similar nodes with `id`, `type`, `content`, `similarity`, `hop_distance`.
|
||||||
|
- **Causal distance (FR-8)** — `GET /api/decisions/causal-distance?source=&target=` traverses only causal-typed edges and returns `CausalDistanceReport` with path, hop count, `confidence_decay`, `weakest_link`, and interpretation.
|
||||||
|
- **Temporal distance history (FR-9)** — `GET /api/temporal/distance-history` samples 11 evenly-spaced snapshots across the graph's time range and emits `convergence | divergence | disconnected | reconnected` events.
|
||||||
|
- **Distance-enriched export (FR-10)** — `POST /api/export/distance-enriched` streams pairwise hop/weighted/semantic/band/centrality metrics as CSV or JSONL. `node_subset` capped at 200 nodes.
|
||||||
|
- **Explorer UI** — Path inspector panel (`GraphInspectorPanel.tsx`) shows a distance band chip, progress-bar metric cards (decay, similarity, coherence), bottleneck node highlight, and interpretation text. Toolbar gains Ego Mode (client-side BFS depth-of-field fading, depth slider 1–8), Structural overlay (edges colored by hop distance), Semantic overlay (edges colored by cosine similarity), and Heatmap (nodes colored green → red by hop distance). Ego and heatmap share a single merged `useEffect` to prevent `restoreNodeColors()` races.
|
||||||
|
- **Tests** — 57 new tests in `tests/context/test_distance_intelligence.py`; 18 targeted regression tests in `tests/_smoke_review_fixes.py`.
|
||||||
|
|
||||||
|
- **Fix: Distance Intelligence — code review regressions** (PR #502 follow-up by @KaifAhmad1):
|
||||||
|
- `GraphWorkspace.tsx` semantic fetch used `?limit=50`; corrected to `?top_k=50` to match the backend param (bug_001). Response type widened to full `SemanticNeighborhoodResponse` shape (bug_002).
|
||||||
|
- `ContextGraph.get_neighbors()` was embedding distance metadata unconditionally, breaking existing callers; gated behind `include_distance_metadata=False` default (bug_003).
|
||||||
|
- `weakest_link` dict key standardised from `weight` → `edge_weight` across `CausalChainAnalyzer` and `CausalDistanceReport` (bug_004).
|
||||||
|
- Temporal distance history sampling replaced `timetuple()[:6]` reconstruction with `min_bound + timedelta(seconds=...)` (bug_005).
|
||||||
|
- Confidence decay in `find_path` was O(E × L); replaced with a single O(E) edge-weight index built before the hop loop, with undirected mirroring (bug_006).
|
||||||
|
- `AgentContext._apply_proximity_metadata()` was overwriting the original record `"id"` with the graph node id; stored as `"graph_node_id"` instead (bug_007).
|
||||||
|
- Path highlight sweep animation used a shared `sweepTimer`; stale callbacks fired after cancellation. Added `sweepGeneration` counter — callbacks no-op if generation no longer matches (bug_008).
|
||||||
|
- `POST /api/export/distance-enriched` now rejects `node_subset` larger than 200 nodes with HTTP 413 (sec_001).
|
||||||
|
- `POST /api/graph/distance-matrix` now computes only the upper triangle and mirrors results, halving computation cost (sec_002).
|
||||||
|
- Ego mode and heatmap `useEffect` hooks merged into one to eliminate concurrent `restoreNodeColors()` race (qual_001).
|
||||||
|
- Bare `except Exception: pass` blocks in `find_path` and `semantic_neighborhood` replaced with `logger.debug(...)` (qual_002).
|
||||||
|
- Duplicated `_distance_band()` static method removed from `CausalChainAnalyzer` and `AgentContext`; both now use `classify_path_distance` from `semantica.utils.helpers` (qual_003).
|
||||||
|
|
||||||
|
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||||
|
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||||
|
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||||
|
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||||
|
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||||
|
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||||
|
- parallel-edge aggregation in full view
|
||||||
|
- collapse behavior preserving active-path neighbors
|
||||||
|
- grouped community-node/community-edge projection behavior
|
||||||
|
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||||
|
|
||||||
|
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||||
|
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||||
|
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||||
|
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
|
||||||
|
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
|
||||||
|
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
|
||||||
|
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
|
||||||
|
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
|
||||||
|
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
|
||||||
|
|
||||||
|
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||||
|
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24–471 ms; a session with 500 k nodes was effectively unusable.
|
||||||
|
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
|
||||||
|
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
|
||||||
|
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
|
||||||
|
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
|
||||||
|
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
|
||||||
|
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
|
||||||
|
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
|
||||||
|
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
|
||||||
|
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
|
||||||
|
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
|
||||||
|
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
|
||||||
|
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
|
||||||
|
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
|
||||||
|
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
|
||||||
|
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
|
||||||
|
|
||||||
|
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||||
|
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||||
|
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||||
|
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||||
|
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||||
|
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||||
|
|
||||||
|
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||||
|
|
||||||
|
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||||
|
|
||||||
|
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||||
|
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
|
||||||
|
|
||||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||||
|
|
||||||
**Critical**
|
**Critical**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:25-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.12-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
[](https://github.com/Hawksight-AI/semantica/actions)
|
[](https://github.com/Hawksight-AI/semantica/actions)
|
||||||
[](https://discord.gg/sV34vps5hH)
|
[](https://discord.gg/sV34vps5hH)
|
||||||
[](https://x.com/BuildSemantica)
|
[](https://x.com/BuildSemantica)
|
||||||
|
[](https://openclaw.ai)
|
||||||
|
|
||||||
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
|
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ Semantica is the **context and intelligence layer** you add on top of your exist
|
|||||||
- ✅ **Reasoning Engines** — forward chaining, Rete networks, deductive, abductive, and SPARQL. Explainable paths, not black boxes.
|
- ✅ **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 LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
|
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install semantica
|
pip install semantica
|
||||||
@@ -53,17 +54,314 @@ pip install semantica
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Plugins (Claude, Cursor, Codex)
|
## 🔌 Works With Every AI Tool
|
||||||
|
|
||||||
Semantica includes a cross-platform plugin bundle under `plugins/` for community use:
|
Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an **MCP server** (`python -m semantica.mcp_server`) for Windsurf, Cline, Continue, VS Code, Claude Desktop, and OpenClaw, and a **REST API** (FastAPI, port 8000) for any other tool.
|
||||||
|
|
||||||
- 17 domain skills (context graphs, decision intelligence, explainability, reasoning, provenance, ontology, temporal, visualization)
|
<table>
|
||||||
- Specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
|
|
||||||
- Hook configuration and platform-specific manifests for Claude, Cursor, and Codex
|
|
||||||
|
|
||||||
See the community setup guide:
|
<!-- ── Native Plugin Bundle ──────────────────────────────────────────── -->
|
||||||
|
<tr>
|
||||||
|
<th colspan="3" align="left">🔌 Native Plugin Bundle</th>
|
||||||
|
<th colspan="5" align="left">⚡ MCP Server + Plugin</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://claude.com/product/claude-code"><img src="https://github.com/anthropics.png?size=120" alt="Claude Code" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Claude Code</strong><br/>
|
||||||
|
<sub>17 skills · 3 agents · hooks</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://cursor.com"><img src="https://www.freelogovectors.net/wp-content/uploads/2025/06/cursor-logo-freelogovectors.net_.png" alt="Cursor" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Cursor</strong><br/>
|
||||||
|
<sub>17 skills · 3 agents</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/openai/codex"><img src="https://github.com/openai.png?size=120" alt="Codex CLI" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Codex CLI</strong><br/>
|
||||||
|
<sub>17 skills · 3 agents</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://windsurf.com"><img src="https://exafunction.github.io/public/brand/windsurf-black-symbol.svg" alt="Windsurf" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Windsurf</strong><br/>
|
||||||
|
<sub><a href="plugins/.windsurf-plugin/">plugin</a></sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/cline/cline"><img src="https://github.com/cline.png?size=120" alt="Cline" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Cline</strong><br/>
|
||||||
|
<sub><a href="plugins/.cline-plugin/">plugin</a></sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/continuedev/continue"><img src="https://github.com/continuedev.png?size=120" alt="Continue" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Continue</strong><br/>
|
||||||
|
<sub><a href="plugins/.continue-plugin/">plugin</a></sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/microsoft/vscode"><img src="https://github.com/microsoft.png?size=120" alt="VS Code" width="48" height="48" /></a><br/>
|
||||||
|
<strong>VS Code</strong><br/>
|
||||||
|
<sub><a href="plugins/.vscode-plugin/">plugin</a></sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="integrations/openclaw/"><img src="https://github.com/openclaw.png?size=120" alt="OpenClaw" width="48" height="48" /></a><br/>
|
||||||
|
<strong>OpenClaw</strong><br/>
|
||||||
|
<sub>MCP + <a href="integrations/openclaw/">plugin</a></sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
- [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
<!-- ── MCP Server only · REST API ───────────────────────────────────── -->
|
||||||
|
<tr>
|
||||||
|
<th colspan="1" align="left">☁️ MCP Server</th>
|
||||||
|
<th colspan="7" align="left">🌐 REST API</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://claude.ai/download"><img src="https://github.com/anthropics.png?size=120" alt="Claude Desktop" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Claude Desktop</strong><br/>
|
||||||
|
<sub>MCP server</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/features/copilot"><img src="https://github.com/github.png?size=120" alt="GitHub Copilot" width="48" height="48" /></a><br/>
|
||||||
|
<strong>GitHub Copilot</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/RooCodeInc/Roo-Code"><img src="https://github.com/RooCodeInc.png?size=120" alt="Roo Code" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Roo Code</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/block/goose"><img src="https://github.com/block.png?size=120" alt="Goose" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Goose</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/Kilo-Org/kilocode"><img src="https://github.com/Kilo-Org.png?size=120" alt="Kilo Code" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Kilo Code</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/Aider-AI/aider"><img src="https://github.com/Aider-AI.png?size=120" alt="Aider" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Aider</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/aws/amazon-q-developer-cli"><img src="https://github.com/aws.png?size=120" alt="Amazon Q" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Amazon Q</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://zed.dev"><img src="https://github.com/zed-industries.png?size=120" alt="Zed" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Zed</strong><br/>
|
||||||
|
<sub>REST API</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ── Any tool via REST ─────────────────────────────────────────────── -->
|
||||||
|
<tr>
|
||||||
|
<th colspan="8" align="left">🔧 Any Tool</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" colspan="8">
|
||||||
|
<img src="https://img.shields.io/badge/109-endpoints-1f6feb?style=flat-square" alt="REST API" width="48" /><br/>
|
||||||
|
<strong>Any agent</strong><br/>
|
||||||
|
<sub>109 REST endpoints · FastAPI · port 8000</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Agentic Frameworks
|
||||||
|
|
||||||
|
Semantica integrates with **Agno** today. Coming soon: LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK, and more.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th colspan="8" align="left">✅ Supported</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/agno-agi/agno"><img src="https://github.com/agno-agi.png?size=120" alt="Agno" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Agno</strong><br/>
|
||||||
|
<sub>First-class · <code>pip install semantica[agno]</code></sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th colspan="8" align="left">🔜 Coming Soon</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/langchain-ai/langchain"><img src="https://github.com/langchain-ai.png?size=120" alt="LangChain" width="48" height="48" /></a><br/>
|
||||||
|
<strong>LangChain</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/langchain-ai/langgraph"><img src="https://github.com/langchain-ai.png?size=120" alt="LangGraph" width="48" height="48" /></a><br/>
|
||||||
|
<strong>LangGraph</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/crewAIInc/crewAI"><img src="https://github.com/crewAIInc.png?size=120" alt="CrewAI" width="48" height="48" /></a><br/>
|
||||||
|
<strong>CrewAI</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/run-llama/llama_index"><img src="https://github.com/run-llama.png?size=120" alt="LlamaIndex" width="48" height="48" /></a><br/>
|
||||||
|
<strong>LlamaIndex</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/microsoft/autogen"><img src="https://github.com/microsoft.png?size=120" alt="AutoGen" width="48" height="48" /></a><br/>
|
||||||
|
<strong>AutoGen</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/openai/openai-agents-python"><img src="https://github.com/openai.png?size=120" alt="OpenAI Agents SDK" width="48" height="48" /></a><br/>
|
||||||
|
<strong>OpenAI Agents</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="12.5%">
|
||||||
|
<a href="https://github.com/google/adk-python"><img src="https://github.com/google.png?size=120" alt="Google ADK" width="48" height="48" /></a><br/>
|
||||||
|
<strong>Google ADK</strong><br/>
|
||||||
|
<sub>Coming soon</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
||||||
|
>
|
||||||
|
> Five integration modules live in [`integrations/agno/`](integrations/agno/):
|
||||||
|
>
|
||||||
|
> | Module | Class | What it does |
|
||||||
|
> |---|---|---|
|
||||||
|
> | `context_store.py` | `AgnoContextStore` | Graph-backed agent memory — store and retrieve structured context |
|
||||||
|
> | `knowledge_graph.py` | `AgnoKnowledgeGraph` | Implements Agno's `AgentKnowledge` protocol; full extraction pipeline |
|
||||||
|
> | `decision_kit.py` | `AgnoDecisionKit` | 6 decision-intelligence tools for Agno agents |
|
||||||
|
> | `kg_toolkit.py` | `AgnoKGToolkit` | 7 KG pipeline tools (build, query, enrich, export) |
|
||||||
|
> | `shared_context.py` | `AgnoSharedContext` | Shared context graph for multi-agent team coordination |
|
||||||
|
|
||||||
|
### Plugin Bundles (Claude Code · Cursor · Codex)
|
||||||
|
|
||||||
|
Native plugin bundles live under [`plugins/`](plugins/). Each directory contains a `plugin.json`, `marketplace.json`, and `README.md`.
|
||||||
|
|
||||||
|
| Bundle | Directory | Tools |
|
||||||
|
|---|---|---|
|
||||||
|
| Claude Code | [`plugins/.claude-plugin/`](plugins/.claude-plugin/) | 17 skills · 3 agents · hooks |
|
||||||
|
| Cursor | [`plugins/.cursor-plugin/`](plugins/.cursor-plugin/) | 17 skills · 3 agents · hooks |
|
||||||
|
| Codex CLI | [`plugins/.codex-plugin/`](plugins/.codex-plugin/) | 17 skills · 3 agents |
|
||||||
|
| Windsurf | [`plugins/.windsurf-plugin/`](plugins/.windsurf-plugin/) | 17 skills · 3 agents · MCP config |
|
||||||
|
| Cline | [`plugins/.cline-plugin/`](plugins/.cline-plugin/) | 17 skills · 3 agents · MCP config |
|
||||||
|
| Continue | [`plugins/.continue-plugin/`](plugins/.continue-plugin/) | 17 skills · 3 agents · MCP config |
|
||||||
|
| VS Code | [`plugins/.vscode-plugin/`](plugins/.vscode-plugin/) | 17 skills · 3 agents · MCP config |
|
||||||
|
| OpenClaw | [`plugins/.openclaw-plugin/`](plugins/.openclaw-plugin/) | 17 skills · 3 agents · MCP config |
|
||||||
|
|
||||||
|
**17 domain skills:**
|
||||||
|
|
||||||
|
| Skill | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `extract` | Full semantic extraction pipeline: NER, relations, events, coreference, triplets |
|
||||||
|
| `ingest` | Data ingestion from files, databases, APIs, streams, and MCP servers |
|
||||||
|
| `query` | SPARQL, Cypher, keyword search, structured graph patterns |
|
||||||
|
| `ontology` | Schema management, concepts, relationships, alignments |
|
||||||
|
| `validate` | Pipeline, extraction, schema, and ontology validation |
|
||||||
|
| `deduplicate` | Duplicate detection and entity merging with fuzzy matching |
|
||||||
|
| `embed` | Node2Vec embeddings, similarity scoring, link prediction |
|
||||||
|
| `reason` | Deductive, abductive, Datalog, SPARQL, and Rete reasoning engines |
|
||||||
|
| `decision` | Record, query, and analyze decisions; find precedents; causal analysis |
|
||||||
|
| `causal` | Cause-effect chains, interventions, counterfactuals, causal influence |
|
||||||
|
| `temporal` | Point-in-time queries, snapshots, timelines, temporal causal analysis |
|
||||||
|
| `provenance` | Data lineage, source attribution, audit trails |
|
||||||
|
| `policy` | Policy definition, enforcement, compliance checks, access control |
|
||||||
|
| `explain` | Decision logic transparency, causal context, audit-ready explanations |
|
||||||
|
| `export` | Multi-format export: JSON, RDF, Parquet, CSV, GraphML |
|
||||||
|
| `change` | Graph change tracking, diffs, temporal updates, impact analysis |
|
||||||
|
| `visualize` | Topology, centrality, communities, paths, embeddings, decision graphs |
|
||||||
|
|
||||||
|
**3 specialized agents:**
|
||||||
|
|
||||||
|
| Agent | Role |
|
||||||
|
|---|---|
|
||||||
|
| `kg-assistant` | General-purpose KG-aware assistant — knows all APIs and method signatures |
|
||||||
|
| `decision-advisor` | Decision intelligence specialist: causal reasoning, precedents, policy violations |
|
||||||
|
| `explainability` | Reasoning transparency specialist — generates audit-ready explanation reports |
|
||||||
|
|
||||||
|
**Hooks** (`plugins/hooks/hooks.json`) — `PreToolUse` / `PostToolUse` matchers for syntax validation and automated warnings.
|
||||||
|
|
||||||
|
→ [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md)
|
||||||
|
|
||||||
|
### MCP Server (expose Semantica to any MCP-aware tool)
|
||||||
|
|
||||||
|
Semantica ships a full **MCP server** (`semantica/mcp_server.py`) — run it once and any MCP-compatible tool connects automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m semantica.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to your tool's config (Claude Desktop, Windsurf, Cline, Continue, VS Code, Roo Code):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "semantica.mcp_server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**12 tools exposed:** `extract_entities`, `extract_relations`, `record_decision`, `query_decisions`, `find_precedents`, `get_causal_chain`, `add_entity`, `add_relationship`, `run_reasoning`, `get_graph_analytics`, `export_graph`, `get_graph_summary`
|
||||||
|
|
||||||
|
**3 resources:** `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`
|
||||||
|
|
||||||
|
See [`plugins/.claude-plugin/README.md`](plugins/.claude-plugin/README.md) for per-tool config snippets.
|
||||||
|
|
||||||
|
### MCP Client (Ingest from MCP Servers)
|
||||||
|
|
||||||
|
Semantica also includes an **MCP client** (`semantica/ingest/mcp_client.py`) that lets you pull data from any Python/FastMCP server into a knowledge graph:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantica.ingest import MCPClient
|
||||||
|
|
||||||
|
client = MCPClient("http://your-mcp-server:8080")
|
||||||
|
resources = client.list_resources() # discover available resources
|
||||||
|
data = client.read_resource("resource://your-data")
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported connection schemes: `http://`, `https://`, `mcp://`, `sse://` · JSON-RPC · auth support · dynamic capability discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Semantica Knowledge Explorer
|
||||||
|
|
||||||
|
A real-time visual interface for exploring every dimension of your knowledge graph — built into the repo under [`explorer/`](explorer/).
|
||||||
|
|
||||||
|
| Workspace | What you can do |
|
||||||
|
|---|---|
|
||||||
|
| **Knowledge Graph** | Pan, zoom, and inspect a live Sigma.js graph canvas with ForceAtlas2 layout |
|
||||||
|
| **Timeline** | Scrub through temporal events and watch the graph evolve |
|
||||||
|
| **Decisions** | Browse the causal chain behind every recorded decision with outcome badges |
|
||||||
|
| **Registry** | Live audit log of every graph mutation — add-node, add-edge, merge, delete |
|
||||||
|
| **Entity Resolution** | Review and merge duplicate entities detected by the deduplication engine |
|
||||||
|
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
|
||||||
|
| **Ontology** | SKOS/OWL vocabulary hierarchy and auto-generated schema summary |
|
||||||
|
|
||||||
|
### Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the Semantica backend (port 8000)
|
||||||
|
python -m semantica.server
|
||||||
|
|
||||||
|
# 2. In a second terminal
|
||||||
|
cd explorer
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:5173** — the Explorer connects automatically. All `/api` and `/ws` traffic is proxied to `127.0.0.1:8000` by Vite, so no CORS configuration is needed.
|
||||||
|
|
||||||
|
> **Requirements:** Node 18+ · Python 3.8+ · npm 9+
|
||||||
|
|
||||||
|
For the full setup guide, troubleshooting, and production build instructions see [`explorer/README.md`](explorer/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -347,6 +645,7 @@ Semantic memory with hybrid search and metadata filtering.
|
|||||||
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
|
| `semantica.change_management` | Version storage, change tracking, checksums, audit trails, compliance support for KGs and ontologies |
|
||||||
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
|
| `semantica.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
|
||||||
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
|
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
|
||||||
|
| [`explorer/`](explorer/) | **Semantica Knowledge Explorer** — React 19 + Sigma.js UI: graph canvas, decision viewer, causal chains, entity resolution, ontology browser, and registry audit log |
|
||||||
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
|
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
|
||||||
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
|
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
|
||||||
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
|
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
|
||||||
@@ -643,16 +942,13 @@ if result.valid:
|
|||||||
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
|
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
|
||||||
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
|
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
|
||||||
- **`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/`)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 Integrations
|
|
||||||
|
|
||||||
### Graph Databases
|
### Graph Databases
|
||||||
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
- **Neo4j** — Cypher queries via `semantica.graph_store`
|
||||||
|
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
|
||||||
- **Apache AGE** — PostgreSQL + openCypher via SQL
|
- **Apache AGE** — PostgreSQL + openCypher via SQL
|
||||||
- **FalkorDB** — native support for decision queries and causal analysis
|
- **AWS Neptune** — Amazon Neptune with IAM authentication
|
||||||
|
|
||||||
### Vector Databases
|
### Vector Databases
|
||||||
- **FAISS** — built-in, zero extra dependencies
|
- **FAISS** — built-in, zero extra dependencies
|
||||||
@@ -668,22 +964,15 @@ if result.valid:
|
|||||||
- **Databases** — SQL via `DBIngestor`
|
- **Databases** — SQL via `DBIngestor`
|
||||||
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
|
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
|
||||||
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
|
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
|
||||||
|
- **Email** — inbox ingestion via `EmailIngestor`
|
||||||
|
- **Repositories** — Git repo ingestion for code graph construction
|
||||||
|
|
||||||
### LLM Providers
|
### LLM Providers
|
||||||
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
|
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
|
||||||
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
|
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
|
||||||
|
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
|
||||||
### Agentic Frameworks
|
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
|
||||||
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
|
- **Ollama** — local models including remote server support
|
||||||
|
|
||||||
> **Agno — First-Class Integration** · `pip install semantica[agno]`
|
|
||||||
>
|
|
||||||
> Five ready-to-use Agno components:
|
|
||||||
> - `AgnoContextStore` — graph-backed agent memory
|
|
||||||
> - `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base
|
|
||||||
> - `AgnoDecisionKit` — 6 decision-intelligence tools
|
|
||||||
> - `AgnoKGToolkit` — 7 KG pipeline tools
|
|
||||||
> - `AgnoSharedContext` — shared context graph for multi-agent teams
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
# Semantica v0.3.0 — Release Notes
|
|
||||||
|
|
||||||
**Released:** 2026-03-10
|
|
||||||
**PyPI:** `pip install semantica`
|
|
||||||
**Tag:** [v0.3.0](https://github.com/Hawksight-AI/semantica/releases/tag/v0.3.0)
|
|
||||||
**Classification:** Production/Stable
|
|
||||||
|
|
||||||
> First stable, full public release of Semantica. Covers everything shipped across three release stages: 0.3.0-alpha (2026-02-19), 0.3.0-beta (2026-03-07), and 0.3.0 stable (2026-03-10).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
| Contributor | Role |
|
|
||||||
|------------|------|
|
|
||||||
| [@KaifAhmad1](https://github.com/KaifAhmad1) | Lead maintainer — context graph, decision intelligence, KG algorithms, semantic extraction, pipeline, provenance, bug fixes, release management |
|
|
||||||
| [@ZohaibHassan16](https://github.com/ZohaibHassan16) | Deduplication v2 suite (candidate generation, two-stage scoring, semantic dedup), incremental/delta processing, benchmark suite |
|
|
||||||
| [@Sameer6305](https://github.com/Sameer6305) | Apache AGE backend, PgVector store, Snowflake connector, Apache Arrow export |
|
|
||||||
| [@tibisabau](https://github.com/tibisabau) | ArangoDB AQL export, Apache Parquet export |
|
|
||||||
| [@d4ndr4d3](https://github.com/d4ndr4d3) | ResourceScheduler deadlock fix |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.0 — Stable (2026-03-10)
|
|
||||||
|
|
||||||
### Context Graph Feature Completeness
|
|
||||||
|
|
||||||
**Temporal Validity Windows** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
Nodes and edges now carry first-class `valid_from` / `valid_until` ISO datetime fields. These are stored directly on `ContextNode` and `ContextEdge` dataclasses — not in metadata — and survive full serialisation round-trips through `save_to_file()` / `load_from_file()` and `to_dict()` / `from_dict()`.
|
|
||||||
|
|
||||||
- `ContextNode.is_active(at_time=None)` and `ContextEdge.is_active(at_time=None)` — returns `True` if the node/edge is live at the given time (defaults to now). Handles both tz-aware and tz-naive datetime inputs correctly.
|
|
||||||
- `ContextGraph.find_active_nodes(node_type=None, at_time=None)` — filters the entire graph and returns only nodes within their validity window.
|
|
||||||
- `add_node(valid_from=..., valid_until=...)` and `add_edge(valid_from=..., valid_until=...)` — pass validity fields directly in the call signature.
|
|
||||||
- Bug fix: `is_active()` previously crashed with `TypeError` when passed a tz-aware `datetime` (e.g. `datetime.now(timezone.utc)`). Fixed by normalising all inputs to tz-naive UTC via a new `_parse_iso_dt()` helper.
|
|
||||||
- Bug fix: validity fields were silently lost in `add_nodes()`, `add_edges()`, `to_dict()`, and `from_dict()`. All four paths now correctly preserve and restore them.
|
|
||||||
|
|
||||||
**Weighted Multi-Hop BFS** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
`ContextGraph.get_neighbors(node_id, hops=1, relationship_types=None, min_weight=0.0)` now accepts a `min_weight` threshold. Any edge with weight below the threshold is skipped during BFS traversal, allowing callers to confine multi-hop queries to high-confidence causal links. Default `0.0` is fully backward-compatible.
|
|
||||||
|
|
||||||
**Cross-Graph Navigation** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
Separate `ContextGraph` instances can now be linked and navigated between — hierarchically, like separate knowledge domains that reference each other.
|
|
||||||
|
|
||||||
- `link_graph(other_graph, source_node_id, target_node_id, link_type="CROSS_GRAPH") -> str` — creates a navigable bridge and returns a `link_id`. Records a dedicated `"cross_graph_link"` typed marker node internally (not a phantom `"entity"`) and a marker edge.
|
|
||||||
- `navigate_to(link_id) -> (other_graph, target_node_id)` — jumps to the target graph and entry node for a given link.
|
|
||||||
- `graph_id` field — each `ContextGraph` now carries a stable UUID so instances can identify each other across save/load.
|
|
||||||
- `save_to_file()` — now writes a `links` section alongside nodes and edges, containing `link_id`, `source_node_id`, `target_node_id`, and `other_graph_id` for every cross-graph link.
|
|
||||||
- `load_from_file()` — restores `graph_id` and populates `_unresolved_links` from the `links` section.
|
|
||||||
- `resolve_links(registry: Dict[str, ContextGraph]) -> int` — reconnects unresolved links post-load. Pass `{graph_id: graph_instance}` for each linked graph; returns the count of successfully resolved links. `navigate_to()` raises a clear `KeyError` with a `resolve_links()` hint if called before resolution.
|
|
||||||
- Bug fix: the previous implementation auto-created the synthetic marker target as an `"entity"` node (phantom pollution). Fixed by explicitly pre-creating a `"cross_graph_link"` typed `ContextNode` before the marker edge.
|
|
||||||
- 14 new tests in `tests/context/test_cross_graph_navigation.py` covering all scenarios including full save/load round-trips with partial registry resolution.
|
|
||||||
|
|
||||||
**Other Fixes** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
- `PipelineBuilder.add_step()` return type annotation corrected from `"PipelineBuilder"` to `"PipelineStep"` — the implementation was already correct; only the annotation and docstring were stale.
|
|
||||||
- `test_hybrid_search_performance` timing computation fixed — now accumulates a true `search_times` list instead of reusing the last loop iteration's `start_time`; threshold relaxed to `< 5.0s` for real `sentence-transformers` (384-dim) latency on development machines.
|
|
||||||
|
|
||||||
**Test Coverage Added**
|
|
||||||
|
|
||||||
- 14 cross-graph navigation tests (`tests/context/test_cross_graph_navigation.py`)
|
|
||||||
- **Total: 335 context tests, 886+ tests across all modules — 0 failures**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.0-beta — Beta (2026-03-07)
|
|
||||||
|
|
||||||
### Semantic Extraction Fixes
|
|
||||||
|
|
||||||
**Multi-Founder LLM Extraction & Reasoner Inference Fix** (PR #354, by @KaifAhmad1)
|
|
||||||
|
|
||||||
- `_parse_relation_result` in `methods.py` — unmatched subjects/objects now produce a synthetic `UNKNOWN` entity instead of silently dropping the relation. All co-founders returned by the LLM are preserved in the output.
|
|
||||||
- Duplicate relation fix — an orphaned legacy block that appended every relation twice has been removed.
|
|
||||||
- `extraction_method` parameter added — typed extraction paths now correctly record `"llm_typed"` in relation metadata instead of `"llm"`.
|
|
||||||
- `_match_pattern` in `reasoner.py` rewritten — splits patterns on `?var` placeholders first, then escapes only literal segments. Pre-bound variables resolve to exact literals, repeated variables use backreferences, non-greedy `.+?` prevents over-consumption of separators.
|
|
||||||
- Added `tests/reasoning/test_reasoner.py` (4 tests) and `tests/semantic_extract/test_relation_extractor.py` (6 tests).
|
|
||||||
|
|
||||||
**TTL Export Alias Fix** (PR #355, by @KaifAhmad1)
|
|
||||||
|
|
||||||
- `RDFExporter` now accepts `"ttl"`, `"nt"`, `"xml"`, `"rdf"`, and `"json-ld"` as format aliases in `export_to_rdf()`. Aliases resolve before format validation — zero public API changes.
|
|
||||||
- Added `tests/export/test_rdf_exporter.py` (8 tests).
|
|
||||||
|
|
||||||
### Incremental / Delta Processing
|
|
||||||
|
|
||||||
**Native Delta Computation** (PR #349, by @ZohaibHassan16, reviewed and fixed by @KaifAhmad1)
|
|
||||||
|
|
||||||
- Native SPARQL-based diff between graph snapshots — only changed triples flow through the pipeline.
|
|
||||||
- `delta_mode` configuration in `PipelineBuilder` for near-real-time workloads.
|
|
||||||
- Version snapshot management with graph URI tracking and metadata storage.
|
|
||||||
- `prune_versions()` for automatic snapshot retention cleanup.
|
|
||||||
- Bug fixes: corrected SPARQL variable order, fixed class references, resolved duplicate dictionary keys.
|
|
||||||
|
|
||||||
### Deduplication v2
|
|
||||||
|
|
||||||
**Candidate Generation v2** (PR #338, by @ZohaibHassan16)
|
|
||||||
|
|
||||||
- New opt-in strategies: `blocking_v2` and `hybrid_v2`, replacing O(N²) pair enumeration.
|
|
||||||
- Multi-key blocking with normalised token prefixes, type-aware keys, and optional phonetic (Soundex) blocking.
|
|
||||||
- Deterministic `max_candidates_per_entity` budgeting with stable sorting.
|
|
||||||
- **63.6% faster** in worst-case scenarios (0.259s → 0.094s for 100 entities).
|
|
||||||
|
|
||||||
**Two-Stage Scoring Prefilter** (PR #339, by @ZohaibHassan16)
|
|
||||||
|
|
||||||
- Fast gates for type mismatch, name-length ratio, and token overlap eliminate expensive semantic scoring for obvious non-matches.
|
|
||||||
- Configurable thresholds: `min_length_ratio`, `min_token_overlap_ratio`, `required_shared_token`.
|
|
||||||
- **18–25% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
|
|
||||||
|
|
||||||
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
|
||||||
|
|
||||||
- Canonicalisation engine with predicate synonym mapping (`works_for` → `employed_by`).
|
|
||||||
- O(1) hash matching for exact canonical signatures.
|
|
||||||
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
|
|
||||||
- **6.98x faster** than legacy mode (83ms vs 579ms).
|
|
||||||
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
|
|
||||||
|
|
||||||
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
|
|
||||||
|
|
||||||
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
|
|
||||||
- Full backward compatibility maintained — legacy mode remains the default.
|
|
||||||
|
|
||||||
### Export Formats
|
|
||||||
|
|
||||||
**ArangoDB AQL Export** (PR #342, by @tibisabau)
|
|
||||||
|
|
||||||
- Full AQL INSERT statement generation for vertices and edges.
|
|
||||||
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
|
|
||||||
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
|
|
||||||
- 17 tests, 100% pass rate.
|
|
||||||
|
|
||||||
**Apache Parquet Export** (PR #343, by @tibisabau)
|
|
||||||
|
|
||||||
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
|
|
||||||
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
|
|
||||||
- `export_parquet()` convenience function; `.parquet` auto-detection.
|
|
||||||
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
|
|
||||||
- 25 tests, 100% pass rate.
|
|
||||||
|
|
||||||
### Bug Fixes & Test Suite Stabilisation
|
|
||||||
|
|
||||||
**Test Suite Fixes** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
Context module:
|
|
||||||
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
|
|
||||||
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
|
|
||||||
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
|
|
||||||
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
|
|
||||||
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
|
|
||||||
|
|
||||||
KG module:
|
|
||||||
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
|
|
||||||
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
|
|
||||||
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
|
|
||||||
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
|
|
||||||
|
|
||||||
Pipeline module:
|
|
||||||
- Retry loop fixed — now correctly iterates to `max_retries`.
|
|
||||||
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
|
|
||||||
- `add_step()` fixed to return the created `PipelineStep`.
|
|
||||||
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
|
|
||||||
|
|
||||||
Other:
|
|
||||||
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
|
|
||||||
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
|
|
||||||
- Windows cp1252 encoding fix in test files (emoji → ASCII).
|
|
||||||
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
|
|
||||||
|
|
||||||
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.0-alpha — Alpha (2026-02-19)
|
|
||||||
|
|
||||||
### Context & Decision Intelligence
|
|
||||||
|
|
||||||
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
|
|
||||||
|
|
||||||
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
|
|
||||||
|
|
||||||
- Full decision lifecycle: `record_decision()` → `add_decision()` → `add_causal_relationship()` → `trace_decision_chain()` → `analyze_decision_impact()` → `analyze_decision_influence()` → `find_similar_decisions()`
|
|
||||||
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
|
|
||||||
- `AgentMemory` with working, conversation, and long-term memory tiers
|
|
||||||
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
|
|
||||||
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
|
|
||||||
- Decision influence analysis via centrality measures and causal chain tracking
|
|
||||||
- GraphStore validation preventing runtime failures; secure logging
|
|
||||||
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
|
|
||||||
|
|
||||||
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
|
|
||||||
|
|
||||||
- Fixed empty/None decision ID handling in `add_decision()`
|
|
||||||
- Fixed None metadata handling preventing `TypeError`
|
|
||||||
- Fixed causal chain depth logic and node exclusion
|
|
||||||
- Fixed nonexistent node handling in `add_causal_relationship()`
|
|
||||||
- Fixed precedent search direction in `find_precedents()`
|
|
||||||
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
|
|
||||||
- Fixed UUID generation across all decision models
|
|
||||||
- All 71 context tests passing
|
|
||||||
|
|
||||||
### Knowledge Graph Algorithms
|
|
||||||
|
|
||||||
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
|
|
||||||
|
|
||||||
- 30+ graph algorithms across 7 categories
|
|
||||||
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
|
|
||||||
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
|
|
||||||
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
|
|
||||||
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
|
|
||||||
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
|
|
||||||
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
|
|
||||||
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
|
|
||||||
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
|
|
||||||
|
|
||||||
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
|
|
||||||
|
|
||||||
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
|
|
||||||
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
|
|
||||||
- `ContextRetriever` with multi-hop reasoning
|
|
||||||
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
|
|
||||||
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
|
|
||||||
|
|
||||||
### Graph Database Backends
|
|
||||||
|
|
||||||
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
|
|
||||||
|
|
||||||
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
|
|
||||||
- SQL injection vulnerabilities fixed with input validation
|
|
||||||
- psycopg2-binary dependency and migration guide added
|
|
||||||
- Fixed parameter replacement and test mock leakage
|
|
||||||
|
|
||||||
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
|
|
||||||
|
|
||||||
- Native PostgreSQL vector storage using the pgvector extension
|
|
||||||
- Multiple distance metrics: cosine, L2/Euclidean, inner product
|
|
||||||
- HNSW and IVFFlat indexing for approximate nearest neighbour search
|
|
||||||
- JSONB metadata storage with flexible filtering; batch operations
|
|
||||||
- Connection pooling with psycopg3/psycopg2 fallback
|
|
||||||
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
|
|
||||||
- 36+ tests with Docker integration
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
|
|
||||||
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
|
|
||||||
|
|
||||||
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
|
|
||||||
- Added `ValidationError` when no resources can be allocated
|
|
||||||
- Progress tracking updates moved outside lock scope
|
|
||||||
- 6 regression tests for deadlock prevention
|
|
||||||
|
|
||||||
**Security Configuration** (by @KaifAhmad1)
|
|
||||||
|
|
||||||
- Dependabot bi-weekly security updates with manual review
|
|
||||||
- Automated security scans (Bandit, Safety, Semgrep) on schedule
|
|
||||||
- Security-critical package grouping; zero auto-merge policy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary by the Numbers
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| Total tests passing | **886+** |
|
|
||||||
| Test failures | **0** |
|
|
||||||
| Context tests | 335 |
|
|
||||||
| KG tests | ~430 |
|
|
||||||
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
|
|
||||||
| Reasoning tests | 19 |
|
|
||||||
| Real-world scenario tests | 85 |
|
|
||||||
| PyPI classifier | Production/Stable |
|
|
||||||
| Python support | 3.8 – 3.12 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Upgrade
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install --upgrade semantica
|
|
||||||
```
|
|
||||||
|
|
||||||
No breaking changes. All new parameters have safe defaults and all new methods are additive.
|
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# Deduplication & Conflict Resolution Strategies Summary
|
|
||||||
|
|
||||||
## Quick Reference by Use Case
|
|
||||||
|
|
||||||
| Use Case | Deduplication Method | Merge Strategy | Conflict Detection | Conflict Resolution |
|
|
||||||
|----------|---------------------|----------------|-------------------|---------------------|
|
|
||||||
| **Finance** |
|
|
||||||
| `01_Financial_Data_Integration_MCP` | `DuplicateDetector` (incremental) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
|
||||||
| `02_Fraud_Detection` | `ClusterBuilder` (graph_based) | `merge_all` | `logical` | `expert_review` |
|
|
||||||
| **Biomedical** |
|
|
||||||
| `01_Drug_Discovery_Pipeline` | `EntityResolver` (semantic) | - | `relationship` | `voting` |
|
|
||||||
| `02_Genomic_Variant_Analysis` | `DuplicateDetector` (group) | `keep_most_complete` | `value` | `credibility_weighted` |
|
|
||||||
| **Cybersecurity** |
|
|
||||||
| `01_Real_Time_Anomaly_Detection` | `DuplicateDetector` (pairwise) | `keep_first` | `entity` | `first_seen` |
|
|
||||||
| `02_Threat_Intelligence_Hybrid_RAG` | `EntityResolver` (exact) | - | `type` | `highest_confidence` |
|
|
||||||
| **Blockchain** |
|
|
||||||
| `01_DeFi_Protocol_Intelligence` | `DuplicateDetector` (group) | `keep_last` | `relationship` | `voting` |
|
|
||||||
| `02_Transaction_Network_Analysis` | `ClusterBuilder` (hierarchical) | `keep_most_complete` | `temporal` | `most_recent` |
|
|
||||||
| **Intelligence** |
|
|
||||||
| `01_Criminal_Network_Analysis` | `EntityResolver` (fuzzy) | - | `value` | `credibility_weighted` |
|
|
||||||
| `02_Intelligence_Analysis_Orchestrator_Worker` | `DuplicateDetector` (batch) | `merge_all` | `entity` | `voting` |
|
|
||||||
| **Renewable Energy** |
|
|
||||||
| `01_Energy_Market_Analysis` | `DuplicateDetector` (pairwise) | `keep_highest_confidence` | `temporal` | `most_recent` |
|
|
||||||
| **Supply Chain** |
|
|
||||||
| `01_Supply_Chain_Data_Integration` | `DuplicateDetector` (incremental) | `keep_most_complete` | `value` | `credibility_weighted` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategy Rationale by Domain
|
|
||||||
|
|
||||||
### Finance
|
|
||||||
- **Financial Data Integration**: Incremental for streaming data; most_recent for time-sensitive financial data
|
|
||||||
- **Fraud Detection**: Graph-based clustering for fraud groups; expert_review for fraud assessment
|
|
||||||
|
|
||||||
### Biomedical
|
|
||||||
- **Drug Discovery**: Semantic matching for drug compounds; voting for research source aggregation
|
|
||||||
- **Genomic Variants**: Group method for related variants; credibility weighting for research sources
|
|
||||||
|
|
||||||
### Cybersecurity
|
|
||||||
- **Real-Time Anomaly**: Pairwise for real-time streams; keep_first for first detection priority
|
|
||||||
- **Threat Intelligence**: Exact matching for IOCs; highest_confidence for threat classification
|
|
||||||
|
|
||||||
### Blockchain
|
|
||||||
- **DeFi Protocols**: Group method for related protocols; keep_last for latest protocol info
|
|
||||||
- **Transaction Networks**: Hierarchical clustering for nested groups; temporal for time-sensitive data
|
|
||||||
|
|
||||||
### Intelligence
|
|
||||||
- **Criminal Networks**: Fuzzy matching for intelligence data; credibility weighting for intelligence sources
|
|
||||||
- **Intelligence Analysis**: Batch for multi-source integration; merge_all to combine all intelligence sources
|
|
||||||
|
|
||||||
### Renewable Energy
|
|
||||||
- **Energy Markets**: Pairwise for real-time market data; most_recent for time-sensitive energy data
|
|
||||||
|
|
||||||
### Supply Chain
|
|
||||||
- **Supply Chain Integration**: Incremental for continuous updates; credibility weighting for supply chain sources
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Method Distribution
|
|
||||||
|
|
||||||
### Deduplication Methods (9 total)
|
|
||||||
- `pairwise`: 2 notebooks (real-time processing)
|
|
||||||
- `batch`: 3 notebooks (large datasets)
|
|
||||||
- `incremental`: 2 notebooks (streaming/continuous)
|
|
||||||
- `group`: 2 notebooks (related entities)
|
|
||||||
- `graph_based` (ClusterBuilder): 2 notebooks (interconnected entities)
|
|
||||||
- `hierarchical` (ClusterBuilder): 1 notebook (nested groups)
|
|
||||||
- `exact` (EntityResolver): 1 notebook (exact matching)
|
|
||||||
- `semantic` (EntityResolver): 2 notebooks (semantic similarity)
|
|
||||||
- `fuzzy` (EntityResolver): 1 notebook (fuzzy matching)
|
|
||||||
|
|
||||||
### Merge Strategies (5 total)
|
|
||||||
- `keep_first`: 1 notebook (first detection priority)
|
|
||||||
- `keep_last`: 1 notebook (latest information)
|
|
||||||
- `keep_most_complete`: 5 notebooks (preserve all details)
|
|
||||||
- `keep_highest_confidence`: 2 notebooks (most reliable data)
|
|
||||||
- `merge_all`: 3 notebooks (combine all information)
|
|
||||||
|
|
||||||
### Conflict Detection Methods (6 total)
|
|
||||||
- `value`: 4 notebooks (property value conflicts)
|
|
||||||
- `type`: 2 notebooks (type/classification conflicts)
|
|
||||||
- `entity`: 2 notebooks (entity-wide conflicts)
|
|
||||||
- `relationship`: 3 notebooks (relationship conflicts)
|
|
||||||
- `temporal`: 3 notebooks (time-sensitive conflicts)
|
|
||||||
- `logical`: 2 notebooks (logical inconsistencies)
|
|
||||||
|
|
||||||
### Conflict Resolution Strategies (6 total)
|
|
||||||
- `voting`: 5 notebooks (majority vote)
|
|
||||||
- `credibility_weighted`: 4 notebooks (source credibility)
|
|
||||||
- `most_recent`: 3 notebooks (latest data)
|
|
||||||
- `first_seen`: 1 notebook (first detection)
|
|
||||||
- `highest_confidence`: 2 notebooks (most confident)
|
|
||||||
- `expert_review`: 1 notebook (manual review)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
1. **Real-Time Systems**: Use `pairwise` + `keep_first` + `first_seen`
|
|
||||||
2. **Time-Sensitive Data**: Use `temporal` + `most_recent`
|
|
||||||
3. **Multi-Source Integration**: Use `batch` + `merge_all` + `voting`
|
|
||||||
4. **Medical/Research**: Use `credibility_weighted` for authoritative sources
|
|
||||||
5. **Fraud/Security**: Use `graph_based` + `logical` + `expert_review`
|
|
||||||
6. **Exact Matching Required**: Use `exact` strategy (IOCs, identifiers)
|
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Semantica Knowledge Explorer
|
||||||
|
|
||||||
|
A real-time visual interface for exploring knowledge graphs, decision intelligence, entity resolution, ontologies, and graph analytics built on top of the [Semantica](https://github.com/Hawksight-AI/semantica) library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Dependency | Minimum Version |
|
||||||
|
|---|---|
|
||||||
|
| Node.js | 18.x or higher (20.x recommended) |
|
||||||
|
| npm | 9.x or higher |
|
||||||
|
| Python | 3.8+ |
|
||||||
|
| Semantica backend | running on `http://127.0.0.1:8000` |
|
||||||
|
|
||||||
|
Check your versions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Local Development)
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Hawksight-AI/semantica.git
|
||||||
|
cd semantica
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install the Semantica Python package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install semantica
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from source if you have the repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Semantica backend
|
||||||
|
|
||||||
|
The Explorer proxies all `/api` and `/ws` requests to `http://127.0.0.1:8000`. The backend must be running before you open the UI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
python -m semantica.server
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend starts on port **8000** by default. Keep this terminal open.
|
||||||
|
|
||||||
|
### 4. Install frontend dependencies
|
||||||
|
|
||||||
|
Open a second terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd explorer
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** This project uses Vite 5 and requires **Node 18+**. If you are on Node 16 or earlier, upgrade first.
|
||||||
|
|
||||||
|
### 5. Start the dev server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite starts on **http://localhost:5173** by default. Open that URL in your browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you should see
|
||||||
|
|
||||||
|
The Explorer opens with a persistent left sidebar and six workspace tabs:
|
||||||
|
|
||||||
|
| Tab | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| **Knowledge Graph** | Interactive Sigma.js canvas — nodes, edges, zoom, ForceAtlas2 layout |
|
||||||
|
| **Timeline** | Temporal event scrubber over the graph |
|
||||||
|
| **Decisions** | Causal chain viewer with outcome badges and decision filter |
|
||||||
|
| **Registry** | Live audit log of every graph mutation (add-node, add-edge, etc.) |
|
||||||
|
| **Entity Resolution** | Duplicate detection and entity merge workflow |
|
||||||
|
| **KG Overview** | Aggregate stats, community breakdown, centrality heatmap |
|
||||||
|
| **Ontology** | SKOS/OWL vocabulary hierarchy and schema summary |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
explorer/
|
||||||
|
├── src/
|
||||||
|
│ ├── App.tsx # Root layout, tab routing, workspace wiring
|
||||||
|
│ ├── index.css # Global resets, fonts, keyframe animations
|
||||||
|
│ ├── store/
|
||||||
|
│ │ └── registryStore.ts # Pub/sub audit registry (no external state lib)
|
||||||
|
│ └── workspaces/
|
||||||
|
│ ├── GraphWorkspace/ # Sigma.js graph canvas + inspector panel
|
||||||
|
│ ├── DecisionWorkspace/ # Causal flow diagram + decision list
|
||||||
|
│ ├── TimelineWorkspace/ # vis-timeline temporal scrubber
|
||||||
|
│ ├── ManageWorkspace/ # Registry, KG Overview, Ontology tabs
|
||||||
|
│ └── EnrichWorkspace/ # Entity resolution tab
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.ts # Dev proxy → 127.0.0.1:8000, build → ../semantica/static
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available scripts
|
||||||
|
|
||||||
|
Run these from inside the `explorer/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the dev server with hot module replacement
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Type-check and build a production bundle into ../semantica/static
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Preview the production build locally
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Run ESLint over all source files
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Run the graph store multi-edge unit tests
|
||||||
|
npm run test:graph-store
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API & WebSocket proxy
|
||||||
|
|
||||||
|
During development, Vite forwards requests automatically — no CORS configuration needed:
|
||||||
|
|
||||||
|
| Pattern | Forwarded to |
|
||||||
|
|---|---|
|
||||||
|
| `/api/*` | `http://127.0.0.1:8000/api/*` |
|
||||||
|
| `/ws` | `ws://127.0.0.1:8000/ws` |
|
||||||
|
|
||||||
|
If you run the backend on a different port, update `server.proxy` in [vite.config.ts](vite.config.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd explorer
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiled assets are written to `../semantica/static/`. The Semantica Python server serves this folder automatically at its root URL — no separate web server needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Blank graph / no data loads**
|
||||||
|
- Make sure the Semantica backend is running (`python -m semantica.server`) before opening the UI.
|
||||||
|
- Check the browser console for failed `/api/graph` requests — the proxy target may need updating in `vite.config.ts`.
|
||||||
|
|
||||||
|
**`npm install` fails or hangs**
|
||||||
|
- Ensure you are using **Node 18 or 20**. Node 16 and Vite 5 are incompatible.
|
||||||
|
- Delete `node_modules/` and `package-lock.json`, then re-run `npm install`.
|
||||||
|
|
||||||
|
**Port 5173 already in use**
|
||||||
|
- Vite will automatically try the next available port and print it in the terminal. Use that URL instead.
|
||||||
|
|
||||||
|
**WebSocket not connecting (real-time mutations not appearing)**
|
||||||
|
- Confirm the backend exposes a `/ws` WebSocket endpoint.
|
||||||
|
- Check browser DevTools → Network → WS tab for the connection status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **React 19** + TypeScript (strict `noUnusedLocals`)
|
||||||
|
- **Vite 5** with `babel-plugin-react-compiler`
|
||||||
|
- **Sigma.js 3** + **Graphology** — graph rendering and in-memory graph store
|
||||||
|
- **ForceAtlas2** — physics-based layout worker
|
||||||
|
- **@tanstack/react-query** — data fetching for ontology and vocab tabs
|
||||||
|
- **vis-timeline** — temporal event visualization
|
||||||
|
- **lucide-react** — icon set
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See the root [CONTRIBUTING.md](../CONTRIBUTING.md) and open issues on the main [Semantica repository](https://github.com/Hawksight-AI/semantica).
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>semantica-explorer</title>
|
<title>Semantica Knowledge Explorer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "semantica-explorer",
|
"name": "semantica-knowledge-explorer",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"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",
|
||||||
@@ -33,19 +34,19 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@babel/core": "^7.29.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@rolldown/plugin-babel": "^0.2.1",
|
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"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": "^8.0.1"
|
"vite": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -10,11 +10,16 @@ const LineageDiagram = lazy(() => import('./workspaces/LineageWorkspace/LineageD
|
|||||||
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
const ReasoningWorkspace = lazy(() => import('./workspaces/ReasoningWorkspace').then((module) => ({ default: module.ReasoningWorkspace })));
|
||||||
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
|
const SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
|
||||||
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
|
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
|
||||||
|
const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab').then((module) => ({ default: module.RegistryTab })));
|
||||||
|
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
|
||||||
|
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||||
|
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||||
|
|
||||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
type WorkspaceId = 'welcome' | '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';
|
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||||
|
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
id: WorkspaceId;
|
id: WorkspaceId;
|
||||||
@@ -26,7 +31,7 @@ type NavItem = {
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
|
{ id: 'explore', label: 'Knowledge Explorer', hint: 'Graph and vocabulary browsing', icon: Database },
|
||||||
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
|
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
|
||||||
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
|
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
|
||||||
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
|
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
|
||||||
@@ -275,19 +280,21 @@ function WorkspaceShell({
|
|||||||
subtitle,
|
subtitle,
|
||||||
tabs,
|
tabs,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
kicker = 'Workspace',
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
tabs?: ReactNode;
|
tabs?: ReactNode;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
kicker?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="workspace-shell">
|
<section className="workspace-shell">
|
||||||
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
|
||||||
<div className="workspace-header-main">
|
<div className="workspace-header-main">
|
||||||
<div className="workspace-kicker">Workspace</div>
|
<div className="workspace-kicker">{kicker}</div>
|
||||||
<div className="workspace-title-block">
|
<div className="workspace-title-block">
|
||||||
<h1 className="workspace-title">{title}</h1>
|
<h1 className="workspace-title">{title}</h1>
|
||||||
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
|
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
|
||||||
@@ -304,18 +311,45 @@ 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>('explore');
|
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||||
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 renderWorkspace = () => {
|
const renderWorkspace = () => {
|
||||||
|
if (activeWorkspace === 'welcome') {
|
||||||
|
return <WelcomeScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeWorkspace === 'explore') {
|
if (activeWorkspace === 'explore') {
|
||||||
return (
|
return (
|
||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
title="Explore"
|
title="Explore"
|
||||||
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
|
||||||
|
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
|
||||||
compact
|
compact
|
||||||
tabs={
|
tabs={
|
||||||
<>
|
<>
|
||||||
@@ -340,6 +374,7 @@ export default function App() {
|
|||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
title="Analyze"
|
title="Analyze"
|
||||||
subtitle="Query the active graph and test inference rules."
|
subtitle="Query the active graph and test inference rules."
|
||||||
|
kicker={analyzeView === 'reasoning' ? 'Reasoning Engine' : 'SPARQL Query'}
|
||||||
tabs={
|
tabs={
|
||||||
<>
|
<>
|
||||||
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
|
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
|
||||||
@@ -363,6 +398,7 @@ export default function App() {
|
|||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
title="Decisions"
|
title="Decisions"
|
||||||
subtitle="Inspect decision chains, causal context, and precedent matches."
|
subtitle="Inspect decision chains, causal context, and precedent matches."
|
||||||
|
kicker="Decision Intelligence"
|
||||||
>
|
>
|
||||||
<Suspense fallback={<WorkspaceFallback />}>
|
<Suspense fallback={<WorkspaceFallback />}>
|
||||||
<DecisionWorkspace />
|
<DecisionWorkspace />
|
||||||
@@ -375,7 +411,8 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
title="Enrich"
|
title="Enrich"
|
||||||
subtitle="Import, export, and reconcile graph entities."
|
subtitle="Import, export, reconcile, and audit graph entities."
|
||||||
|
kicker="Knowledge Audit"
|
||||||
tabs={
|
tabs={
|
||||||
<>
|
<>
|
||||||
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
|
<button className="workspace-tab" data-active={enrichView === 'import'} onClick={() => setEnrichView('import')}>
|
||||||
@@ -384,11 +421,20 @@ export default function App() {
|
|||||||
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
|
<button className="workspace-tab" data-active={enrichView === 'merge'} onClick={() => setEnrichView('merge')}>
|
||||||
Diff and Merge
|
Diff and Merge
|
||||||
</button>
|
</button>
|
||||||
|
<button className="workspace-tab" data-active={enrichView === 'resolve'} onClick={() => setEnrichView('resolve')}>
|
||||||
|
Entity Resolution
|
||||||
|
</button>
|
||||||
|
<button className="workspace-tab" data-active={enrichView === 'registry'} onClick={() => setEnrichView('registry')}>
|
||||||
|
Registry
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<WorkspaceFallback />}>
|
<Suspense fallback={<WorkspaceFallback />}>
|
||||||
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
|
{enrichView === 'import' ? <ImportExportWorkspace /> :
|
||||||
|
enrichView === 'merge' ? <DiffMergeWorkspace /> :
|
||||||
|
enrichView === 'resolve' ? <EntityResolutionTab /> :
|
||||||
|
<RegistryTab />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WorkspaceShell>
|
</WorkspaceShell>
|
||||||
);
|
);
|
||||||
@@ -397,10 +443,29 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<WorkspaceShell
|
<WorkspaceShell
|
||||||
title="Manage"
|
title="Manage"
|
||||||
subtitle="Review provenance, lineage, and governance context."
|
subtitle="Review provenance, lineage, ontology, and governance context."
|
||||||
|
kicker="Graph Governance"
|
||||||
|
tabs={
|
||||||
|
<>
|
||||||
|
<button className="workspace-tab" data-active={manageView === 'lineage'} onClick={() => setManageView('lineage')}>
|
||||||
|
PROV-O Lineage
|
||||||
|
</button>
|
||||||
|
<button className="workspace-tab" data-active={manageView === 'kg-overview'} onClick={() => setManageView('kg-overview')}>
|
||||||
|
KG Overview
|
||||||
|
</button>
|
||||||
|
<button className="workspace-tab" data-active={manageView === 'ontology'} onClick={() => setManageView('ontology')}>
|
||||||
|
Ontology Summary
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<WorkspaceFallback />}>
|
<Suspense fallback={<WorkspaceFallback />}>
|
||||||
<LineageDiagram />
|
{manageView === 'lineage' ? <LineageDiagram /> :
|
||||||
|
manageView === 'kg-overview' ? <KGOverviewTab /> :
|
||||||
|
<OntologySummaryTab onOpenVocabularyBrowser={() => {
|
||||||
|
setActiveWorkspace('explore');
|
||||||
|
setExploreView('vocabulary');
|
||||||
|
}} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WorkspaceShell>
|
</WorkspaceShell>
|
||||||
);
|
);
|
||||||
@@ -411,7 +476,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">
|
||||||
<div className="brand-pill">SEM</div>
|
<button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
|
||||||
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
{navItems.map(({ id, label, hint, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -63,3 +63,9 @@ code, pre, .mono {
|
|||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton pulse animation for loading placeholders */
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.45; }
|
||||||
|
50% { opacity: 0.85; }
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ 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;
|
||||||
@@ -74,6 +78,12 @@ 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;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* src/store/registryStore.ts
|
||||||
|
*
|
||||||
|
* Lightweight client-side audit log for all KG / Ontology mutations.
|
||||||
|
* No backend required — events are dispatched by each workspace after
|
||||||
|
* a successful API call or WebSocket mutation.
|
||||||
|
*
|
||||||
|
* Any component can call logEvent() from anywhere (including non-React code).
|
||||||
|
* React components subscribe via the useRegistry() hook.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export type RegistryEntryOp =
|
||||||
|
| "import"
|
||||||
|
| "export"
|
||||||
|
| "merge"
|
||||||
|
| "add-node"
|
||||||
|
| "add-edge"
|
||||||
|
| "delete"
|
||||||
|
| "infer"
|
||||||
|
| "vocab-import";
|
||||||
|
|
||||||
|
export interface RegistryEntry {
|
||||||
|
id: string;
|
||||||
|
op: RegistryEntryOp;
|
||||||
|
timestamp: Date;
|
||||||
|
summary: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (entries: readonly RegistryEntry[]) => void;
|
||||||
|
|
||||||
|
let _entries: RegistryEntry[] = [];
|
||||||
|
const _listeners = new Set<Listener>();
|
||||||
|
const MAX_ENTRIES = 500;
|
||||||
|
|
||||||
|
function _notify(): void {
|
||||||
|
_listeners.forEach((fn) => fn(_entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logEvent(
|
||||||
|
op: RegistryEntryOp,
|
||||||
|
summary: string,
|
||||||
|
detail?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const entry: RegistryEntry = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
op,
|
||||||
|
timestamp: new Date(),
|
||||||
|
summary,
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
_entries = [entry, ..._entries].slice(0, MAX_ENTRIES);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRegistry(): void {
|
||||||
|
_entries = [];
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistryEntries(): readonly RegistryEntry[] {
|
||||||
|
return _entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegistry(): readonly RegistryEntry[] {
|
||||||
|
const [snapshot, setSnapshot] = useState<readonly RegistryEntry[]>(_entries);
|
||||||
|
useEffect(() => {
|
||||||
|
// Sync any events that arrived between render and subscribe
|
||||||
|
setSnapshot(_entries);
|
||||||
|
_listeners.add(setSnapshot);
|
||||||
|
return () => {
|
||||||
|
_listeners.delete(setSnapshot);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Scale, Search } from "lucide-react";
|
||||||
|
|
||||||
|
const THEME_CSS = `
|
||||||
|
.glass-panel {
|
||||||
|
background: linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6));
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
border: 1px solid rgba(88,166,255,0.2);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 1px 1px 0 rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { opacity: 0.45; }
|
||||||
|
50% { opacity: 0.85; }
|
||||||
|
100% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
.skeleton-item {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type OutcomeKind = "approved" | "rejected" | "deferred" | "pending" | string;
|
||||||
|
|
||||||
|
function outcomeStyle(outcome: string): { color: string; bg: string; border: string } {
|
||||||
|
const lower = (outcome ?? "").toLowerCase();
|
||||||
|
if (lower.includes("approv") || lower.includes("accept"))
|
||||||
|
return { color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" };
|
||||||
|
if (lower.includes("reject") || lower.includes("denied") || lower.includes("fail"))
|
||||||
|
return { color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" };
|
||||||
|
if (lower.includes("defer") || lower.includes("pending") || lower.includes("review"))
|
||||||
|
return { color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" };
|
||||||
|
return { color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutcomeBadge({ outcome }: { outcome: OutcomeKind }) {
|
||||||
|
const style = outcomeStyle(outcome);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: style.color,
|
||||||
|
background: style.bg,
|
||||||
|
border: `1px solid ${style.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{outcome || "unknown"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonList() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="skeleton-item" style={{ height: 62 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Causal Flow Diagram ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface ChainStep {
|
||||||
|
id: string;
|
||||||
|
relationship: string;
|
||||||
|
content?: string;
|
||||||
|
type?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationshipPill({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0, position: "relative", margin: "0 auto" }}>
|
||||||
|
{/* Connector line top */}
|
||||||
|
<div style={{ width: 2, height: 12, background: "rgba(88,166,255,0.25)" }} />
|
||||||
|
{/* Pill */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "3px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "#79c0ff",
|
||||||
|
background: "rgba(88,166,255,0.1)",
|
||||||
|
border: "1px solid rgba(88,166,255,0.22)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: 260,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{/* Connector line bottom + arrow */}
|
||||||
|
<div style={{ width: 2, height: 10, background: "rgba(88,166,255,0.25)" }} />
|
||||||
|
<div style={{ width: 0, height: 0, borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderTop: "6px solid rgba(88,166,255,0.4)" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChainNodeCard({ step, index }: { step: ChainStep; index: number }) {
|
||||||
|
const COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff"];
|
||||||
|
const color = COLORS[index % COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.5))",
|
||||||
|
border: `1px solid ${color}33`,
|
||||||
|
boxShadow: `0 0 0 1px ${color}11, inset 0 1px 0 rgba(255,255,255,0.04)`,
|
||||||
|
borderLeft: `3px solid ${color}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8, height: 8, borderRadius: "50%",
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 8px ${color}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{step.type ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase", color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.type}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{step.content || step.id}
|
||||||
|
</div>
|
||||||
|
{step.id && step.id !== step.content ? (
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", marginTop: 3 }}>{step.id}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CausalFlowDiagram({ chain, loading }: { chain: ChainStep[]; loading: boolean }) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="skeleton-item" style={{ height: 68 }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: "center", padding: "40px 24px", color: "#8b949e", fontSize: 13 }}>
|
||||||
|
No causal chain steps found for this decision.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "stretch" }}>
|
||||||
|
{chain.map((step, index) => (
|
||||||
|
<div key={`${step.id}-${index}`} style={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<ChainNodeCard step={step} index={index} />
|
||||||
|
{index < chain.length - 1 ? (
|
||||||
|
<RelationshipPill label={chain[index + 1]?.relationship || "→"} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Workspace ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function DecisionWorkspace() {
|
||||||
|
const [decisions, setDecisions] = useState<any[]>([]);
|
||||||
|
const [selectedDecision, setSelectedDecision] = useState<any | null>(null);
|
||||||
|
const [chain, setChain] = useState<ChainStep[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setListLoading(true);
|
||||||
|
fetch("/api/decisions", { signal: controller.signal })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`Failed to load decisions: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setDecisions(data);
|
||||||
|
if (data.length > 0) void handleSelectDecision(data[0]);
|
||||||
|
})
|
||||||
|
.catch((err) => { if (err.name !== "AbortError") console.error(err); })
|
||||||
|
.finally(() => setListLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredDecisions = useMemo(() => {
|
||||||
|
if (!filterQuery.trim()) return decisions;
|
||||||
|
const q = filterQuery.toLowerCase();
|
||||||
|
return decisions.filter(
|
||||||
|
(d) =>
|
||||||
|
String(d.decision_id ?? "").toLowerCase().includes(q) ||
|
||||||
|
String(d.category ?? "").toLowerCase().includes(q) ||
|
||||||
|
String(d.outcome ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [decisions, filterQuery]);
|
||||||
|
|
||||||
|
const handleSelectDecision = async (d: any) => {
|
||||||
|
setSelectedDecision(d);
|
||||||
|
setLoading(true);
|
||||||
|
setChain([]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/decisions/${encodeURIComponent(d.decision_id)}/chain`, { signal: controller.signal });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load chain: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setChain(data.chain || []);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as DOMException).name !== "AbortError") console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
return () => controller.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", width: "100%", height: "100%", background: "#0d1117", overflow: "hidden" }}>
|
||||||
|
<style>{THEME_CSS}</style>
|
||||||
|
|
||||||
|
{/* Left Column — Decision List */}
|
||||||
|
<div
|
||||||
|
className="glass-panel"
|
||||||
|
style={{
|
||||||
|
width: 300,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: 0,
|
||||||
|
border: "none",
|
||||||
|
borderRight: "1px solid rgba(88,166,255,0.16)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* List header */}
|
||||||
|
<div style={{ padding: "20px 20px 14px", borderBottom: "1px solid rgba(255,255,255,0.06)", flexShrink: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||||
|
<Scale size={16} color="#4aa3ff" />
|
||||||
|
<h2 style={{ color: "#ebf3ff", margin: 0, fontSize: 15, fontWeight: 700 }}>Decisions</h2>
|
||||||
|
{decisions.length > 0 ? (
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>{decisions.length}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter input */}
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<Search
|
||||||
|
size={13}
|
||||||
|
color="#8b949e"
|
||||||
|
style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", pointerEvents: "none" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter decisions…"
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e) => setFilterQuery(e.target.value)}
|
||||||
|
style={filterInputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision list */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "12px 14px" }}>
|
||||||
|
{listLoading ? (
|
||||||
|
<SkeletonList />
|
||||||
|
) : filteredDecisions.length === 0 ? (
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 13, textAlign: "center", padding: "32px 12px" }}>
|
||||||
|
{decisions.length === 0 ? "No decisions available." : "No decisions match your filter."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{filteredDecisions.map((d) => {
|
||||||
|
const isActive = selectedDecision?.decision_id === d.decision_id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.decision_id}
|
||||||
|
onClick={() => void handleSelectDecision(d)}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: isActive
|
||||||
|
? "rgba(74,163,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.02)",
|
||||||
|
border: isActive
|
||||||
|
? "1px solid rgba(74,163,255,0.32)"
|
||||||
|
: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
color: isActive ? "#ffffff" : "#c6d4e3",
|
||||||
|
transition: "all 160ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{d.decision_id}</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{d.category ? (
|
||||||
|
<span style={{ fontSize: 11, color: "#8b949e" }}>{d.category}</span>
|
||||||
|
) : null}
|
||||||
|
{d.outcome ? <OutcomeBadge outcome={d.outcome} /> : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column — Decision Detail */}
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
|
{/* Radial accent */}
|
||||||
|
<div style={{ position: "absolute", inset: 0, background: "radial-gradient(ellipse at top right, rgba(88,166,255,0.04), transparent 55%)", pointerEvents: "none", zIndex: 0 }} />
|
||||||
|
|
||||||
|
{selectedDecision ? (
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "28px 32px", position: "relative", zIndex: 1 }}>
|
||||||
|
{/* Decision header */}
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 6 }}>
|
||||||
|
Decision ID
|
||||||
|
</div>
|
||||||
|
<h1 style={{ color: "#ffffff", fontSize: 24, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 8px 0", wordBreak: "break-word" }}>
|
||||||
|
{selectedDecision.decision_id}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{selectedDecision.outcome ? <OutcomeBadge outcome={selectedDecision.outcome} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDecision.category ? (
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 10px", borderRadius: 999, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.08)", color: "#8b949e", fontSize: 12 }}>
|
||||||
|
{selectedDecision.category}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Causal Chain */}
|
||||||
|
<div className="glass-panel" style={{ padding: 24, borderRadius: 16 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 20 }}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "linear-gradient(135deg, #4aa3ff, #f2b66d)", boxShadow: "0 0 10px rgba(74,163,255,0.4)" }} />
|
||||||
|
<h3 style={{ color: "#e6edf3", margin: 0, fontSize: 14, fontWeight: 700, letterSpacing: "0.02em" }}>
|
||||||
|
Causal Chain
|
||||||
|
</h3>
|
||||||
|
{chain.length > 0 && !loading ? (
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11, marginLeft: "auto" }}>
|
||||||
|
{chain.length} step{chain.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<CausalFlowDiagram chain={chain} loading={loading} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#8b949e", fontSize: 14 }}>
|
||||||
|
Select a decision to inspect its causal chain.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const filterInputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 10px 7px 30px",
|
||||||
|
background: "rgba(0,0,0,0.25)",
|
||||||
|
border: "1px solid rgba(88,166,255,0.16)",
|
||||||
|
borderRadius: 8,
|
||||||
|
color: "#c6d4e3",
|
||||||
|
fontSize: 12,
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
* src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { logEvent } from "../../store/registryStore";
|
||||||
|
|
||||||
const THEME_CSS = `
|
const THEME_CSS = `
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
@@ -29,6 +30,11 @@ export function DiffMergeWorkspace() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.merged_into) {
|
if (data.merged_into) {
|
||||||
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
|
||||||
|
logEvent("merge", `Merged ${duplicateId} → ${data.merged_into} · ${data.edges_updated} edges redirected`, {
|
||||||
|
primary: data.merged_into,
|
||||||
|
duplicate: duplicateId,
|
||||||
|
edgesUpdated: data.edges_updated,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setMsg("Merge failed...");
|
setMsg("Merge failed...");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx
|
||||||
|
*
|
||||||
|
* Entity Resolution — run duplicate detection, review flagged pairs,
|
||||||
|
* perform one-click merges, and view merge history from the Registry.
|
||||||
|
*/
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { ScanSearch, GitMerge, X, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { logEvent, useRegistry } from "../../store/registryStore";
|
||||||
|
|
||||||
|
interface DedupPair {
|
||||||
|
a: { id: string; label: string; type: string };
|
||||||
|
b: { id: string; label: string; type: string };
|
||||||
|
score: number;
|
||||||
|
dismissed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawDuplicateItem {
|
||||||
|
entity_a?: string | Record<string, unknown>;
|
||||||
|
entity_b?: string | Record<string, unknown>;
|
||||||
|
similarity?: number;
|
||||||
|
score?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractId(entity: string | Record<string, unknown> | undefined): string {
|
||||||
|
if (!entity) return "";
|
||||||
|
if (typeof entity === "string") return entity;
|
||||||
|
return String(entity.id ?? entity.text ?? JSON.stringify(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLabel(entity: string | Record<string, unknown> | undefined): string {
|
||||||
|
if (!entity) return "";
|
||||||
|
if (typeof entity === "string") return entity;
|
||||||
|
return String(entity.text ?? entity.label ?? entity.content ?? entity.id ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractType(entity: string | Record<string, unknown> | undefined): string {
|
||||||
|
if (!entity || typeof entity === "string") return "entity";
|
||||||
|
return String(entity.type ?? "entity");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuplicates(raw: RawDuplicateItem[]): DedupPair[] {
|
||||||
|
return raw.map((item) => ({
|
||||||
|
a: {
|
||||||
|
id: extractId(item.entity_a as string | Record<string, unknown>),
|
||||||
|
label: extractLabel(item.entity_a as string | Record<string, unknown>),
|
||||||
|
type: extractType(item.entity_a as string | Record<string, unknown>),
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
id: extractId(item.entity_b as string | Record<string, unknown>),
|
||||||
|
label: extractLabel(item.entity_b as string | Record<string, unknown>),
|
||||||
|
type: extractType(item.entity_b as string | Record<string, unknown>),
|
||||||
|
},
|
||||||
|
score: Number(item.similarity ?? item.score ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBar({ score }: { score: number }) {
|
||||||
|
const pct = Math.min(100, Math.round(score * 100));
|
||||||
|
const color = score >= 0.9 ? "#ff7b72" : score >= 0.75 ? "#f2b66d" : "#4cc38a";
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, height: 4, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 999, background: color, transition: "width 300ms ease" }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color, minWidth: 34, textAlign: "right" }}>
|
||||||
|
{pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PairRow({
|
||||||
|
pair,
|
||||||
|
onMerge,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
pair: DedupPair;
|
||||||
|
onMerge: (primaryId: string, duplicateId: string) => Promise<void>;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [merging, setMerging] = useState(false);
|
||||||
|
|
||||||
|
const handleMerge = async () => {
|
||||||
|
setMerging(true);
|
||||||
|
await onMerge(pair.a.id, pair.b.id);
|
||||||
|
setMerging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={pairCardStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||||
|
{/* Expand */}
|
||||||
|
<button onClick={() => setExpanded((v) => !v)} style={iconBtnStyle}>
|
||||||
|
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Entity Labels */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span style={entityChipStyle}>{pair.a.label || pair.a.id}</span>
|
||||||
|
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>≈</span>
|
||||||
|
<span style={entityChipStyle}>{pair.b.label || pair.b.id}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<ScoreBar score={pair.score} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleMerge()}
|
||||||
|
disabled={merging}
|
||||||
|
style={{
|
||||||
|
...actionBtnStyle,
|
||||||
|
background: "rgba(76,195,138,0.12)",
|
||||||
|
border: "1px solid rgba(76,195,138,0.28)",
|
||||||
|
color: "#4cc38a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{merging ? <Loader2 size={12} className="animate-spin" /> : <GitMerge size={12} />}
|
||||||
|
<span>Merge</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={onDismiss} style={iconBtnStyle} title="Dismiss">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded diff */}
|
||||||
|
{expanded ? (
|
||||||
|
<div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
||||||
|
{[
|
||||||
|
{ label: "Primary (keep)", entity: pair.a, accentColor: "#4aa3ff" },
|
||||||
|
{ label: "Duplicate (remove)", entity: pair.b, accentColor: "#ff7b72" },
|
||||||
|
].map(({ label, entity, accentColor }) => (
|
||||||
|
<div key={entity.id} style={{ ...diffCardStyle, borderColor: `${accentColor}33` }}>
|
||||||
|
<div style={{ color: accentColor, fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 6 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600 }}>{entity.label || entity.id}</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>{entity.type}</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 10, marginTop: 4, fontFamily: "monospace" }}>{entity.id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityResolutionTab() {
|
||||||
|
const [threshold, setThreshold] = useState(0.82);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [pairs, setPairs] = useState<DedupPair[]>([]);
|
||||||
|
const [scanError, setScanError] = useState("");
|
||||||
|
const registryEntries = useRegistry();
|
||||||
|
|
||||||
|
const mergeHistory = registryEntries.filter((e) => e.op === "merge");
|
||||||
|
|
||||||
|
const handleScan = useCallback(async () => {
|
||||||
|
setScanning(true);
|
||||||
|
setScanError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/enrich/dedup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ threshold }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as Record<string, string>).detail ?? `Scan failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const rawDuplicates: RawDuplicateItem[] = Array.isArray(data.duplicates)
|
||||||
|
? (data.duplicates as RawDuplicateItem[])
|
||||||
|
: [];
|
||||||
|
const parsed = parseDuplicates(rawDuplicates);
|
||||||
|
setPairs(parsed);
|
||||||
|
logEvent("import", `Dedup scan found ${parsed.length} flagged pair${parsed.length !== 1 ? "s" : ""} (threshold ${threshold.toFixed(2)})`, {
|
||||||
|
threshold,
|
||||||
|
flagged: parsed.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setScanError(err instanceof Error ? err.message : "Scan failed");
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
const handleMerge = useCallback(async (primaryId: string, duplicateId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/enrich/merge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ primary_id: primaryId, duplicate_ids: [duplicateId] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Merge failed (${res.status})`);
|
||||||
|
const data = await res.json();
|
||||||
|
logEvent("merge", `Merged ${duplicateId} → ${primaryId} · ${data.edges_updated ?? 0} edges redirected`, {
|
||||||
|
primary: primaryId,
|
||||||
|
duplicate: duplicateId,
|
||||||
|
edgesUpdated: data.edges_updated,
|
||||||
|
});
|
||||||
|
setPairs((prev) => prev.filter((p) => !(p.a.id === primaryId && p.b.id === duplicateId)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[EntityResolution] merge failed", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback((index: number) => {
|
||||||
|
setPairs((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<ScanSearch size={18} color="#f2b66d" />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Entity Resolution</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>Detect and merge duplicate entities in the knowledge graph</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan controls */}
|
||||||
|
<div style={controlsCardStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 240 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
|
||||||
|
<label style={{ color: "#c6d4e3", fontSize: 12, fontWeight: 600 }}>Similarity Threshold</label>
|
||||||
|
<span style={{ color: "#f2b66d", fontSize: 12, fontWeight: 700 }}>{threshold.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.5}
|
||||||
|
max={0.99}
|
||||||
|
step={0.01}
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||||
|
style={{ width: "100%", accentColor: "#f2b66d", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", color: "#6a7f97", fontSize: 10, marginTop: 2 }}>
|
||||||
|
<span>More results (0.50)</span>
|
||||||
|
<span>Fewer, higher confidence (0.99)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleScan()}
|
||||||
|
disabled={scanning}
|
||||||
|
style={scanBtnStyle}
|
||||||
|
>
|
||||||
|
{scanning ? <Loader2 size={14} className="animate-spin" /> : <ScanSearch size={14} />}
|
||||||
|
<span>{scanning ? "Scanning…" : "Run Dedup Scan"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{scanError ? (
|
||||||
|
<div style={{ color: "#ff7b72", fontSize: 12, marginTop: 8 }}>{scanError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", gap: 0 }}>
|
||||||
|
{/* Flagged pairs */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "16px 24px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{pairs.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12, fontWeight: 600 }}>
|
||||||
|
{pairs.length} flagged pair{pairs.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setPairs([])} style={clearAllBtnStyle}>Clear all</button>
|
||||||
|
</div>
|
||||||
|
{pairs.map((pair, index) => (
|
||||||
|
<PairRow
|
||||||
|
key={`${pair.a.id}:${pair.b.id}`}
|
||||||
|
pair={pair}
|
||||||
|
onMerge={handleMerge}
|
||||||
|
onDismiss={() => handleDismiss(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={emptyStateStyle}>
|
||||||
|
<ScanSearch size={36} color="rgba(242,182,109,0.15)" />
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||||
|
No flagged pairs
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 280 }}>
|
||||||
|
Set a similarity threshold and run a dedup scan to detect potential duplicates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Merge history sidebar */}
|
||||||
|
{mergeHistory.length > 0 ? (
|
||||||
|
<div style={historyPanelStyle}>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", marginBottom: 10 }}>
|
||||||
|
Merge History
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{mergeHistory.map((entry) => (
|
||||||
|
<div key={entry.id} style={historyRowStyle}>
|
||||||
|
<GitMerge size={11} color="#f2b66d" />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: "#c6d4e3", fontSize: 11, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{entry.summary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 10 }}>
|
||||||
|
{entry.timestamp.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const shellStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "#0d1117",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
padding: "20px 24px 16px",
|
||||||
|
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlsCardStyle: React.CSSProperties = {
|
||||||
|
margin: "16px 24px",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.75), rgba(22,27,34,0.6))",
|
||||||
|
border: "1px solid rgba(242,182,109,0.18)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanBtnStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 7,
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "linear-gradient(135deg, rgba(242,182,109,0.22), rgba(242,182,109,0.1))",
|
||||||
|
border: "1px solid rgba(242,182,109,0.32)",
|
||||||
|
color: "#f2b66d",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pairCardStyle: React.CSSProperties = {
|
||||||
|
padding: "12px 14px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||||
|
border: "1px solid rgba(255,255,255,0.07)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const entityChipStyle: React.CSSProperties = {
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
color: "#e6edf3",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBtnStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBtnStyle: React.CSSProperties = {
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#8b949e",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const diffCardStyle: React.CSSProperties = {
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "rgba(0,0,0,0.2)",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyPanelStyle: React.CSSProperties = {
|
||||||
|
width: 240,
|
||||||
|
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
padding: "16px 16px",
|
||||||
|
overflowY: "auto",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const historyRowStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 7,
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStateStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 40,
|
||||||
|
minHeight: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllBtnStyle: React.CSSProperties = {
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 6,
|
||||||
|
};
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* src/workspaces/EnrichWorkspace/RegistryTab.tsx
|
||||||
|
*
|
||||||
|
* Document Registry — a live, filterable chronological audit log of every
|
||||||
|
* KG / Ontology mutation that occurred in this session.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ClipboardList, Filter, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { useRegistry, clearRegistry, type RegistryEntryOp } from "../../store/registryStore";
|
||||||
|
|
||||||
|
const OP_META: Record<
|
||||||
|
RegistryEntryOp,
|
||||||
|
{ label: string; color: string; bg: string; border: string }
|
||||||
|
> = {
|
||||||
|
import: { label: "IMPORT", color: "#4aa3ff", bg: "rgba(74,163,255,0.12)", border: "rgba(74,163,255,0.28)" },
|
||||||
|
export: { label: "EXPORT", color: "#8fa8c6", bg: "rgba(143,168,198,0.08)", border: "rgba(143,168,198,0.18)" },
|
||||||
|
merge: { label: "MERGE", color: "#f2b66d", bg: "rgba(242,182,109,0.12)", border: "rgba(242,182,109,0.28)" },
|
||||||
|
"add-node": { label: "ADD NODE", color: "#4cc38a", bg: "rgba(76,195,138,0.12)", border: "rgba(76,195,138,0.28)" },
|
||||||
|
"add-edge": { label: "ADD EDGE", color: "#4cc38a", bg: "rgba(76,195,138,0.10)", border: "rgba(76,195,138,0.22)" },
|
||||||
|
delete: { label: "DELETE", color: "#ff7b72", bg: "rgba(255,123,114,0.12)", border: "rgba(255,123,114,0.28)" },
|
||||||
|
infer: { label: "INFER", color: "#d2a8ff", bg: "rgba(210,168,255,0.12)", border: "rgba(210,168,255,0.28)" },
|
||||||
|
"vocab-import": { label: "VOCAB", color: "#79c0ff", bg: "rgba(121,192,255,0.12)", border: "rgba(121,192,255,0.28)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_OPS: (RegistryEntryOp | "all")[] = [
|
||||||
|
"all", "import", "export", "merge", "add-node", "add-edge", "infer", "delete", "vocab-import",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function EntryRow({ entry }: { entry: ReturnType<typeof useRegistry>[number] }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const meta = OP_META[entry.op];
|
||||||
|
const hasDetail = entry.detail && Object.keys(entry.detail).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={entryCardStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||||
|
{/* Op Badge */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
color: meta.color,
|
||||||
|
background: meta.bg,
|
||||||
|
border: `1px solid ${meta.border}`,
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 500, wordBreak: "break-word" }}>
|
||||||
|
{entry.summary}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11, marginTop: 3 }}>
|
||||||
|
{formatDate(entry.timestamp)} · {formatTimestamp(entry.timestamp)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expand toggle */}
|
||||||
|
{hasDetail ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
title={expanded ? "Collapse details" : "Expand details"}
|
||||||
|
style={expandBtnStyle}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded detail */}
|
||||||
|
{expanded && hasDetail ? (
|
||||||
|
<pre style={detailPreStyle}>
|
||||||
|
{JSON.stringify(entry.detail, null, 2)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegistryTab() {
|
||||||
|
const entries = useRegistry();
|
||||||
|
const [activeFilter, setActiveFilter] = useState<RegistryEntryOp | "all">("all");
|
||||||
|
|
||||||
|
const filtered = activeFilter === "all"
|
||||||
|
? entries
|
||||||
|
: entries.filter((e) => e.op === activeFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<ClipboardList size={18} color="#4aa3ff" />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Document Registry</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||||
|
Audit log of all KG and Ontology mutations this session
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ color: "#8fa8c6", fontSize: 12 }}>
|
||||||
|
{entries.length} event{entries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<button
|
||||||
|
onClick={clearRegistry}
|
||||||
|
title="Clear all events"
|
||||||
|
style={clearBtnStyle}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
<span>Clear</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter pills */}
|
||||||
|
<div style={filterBarStyle}>
|
||||||
|
<Filter size={13} color="#8fa8c6" />
|
||||||
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{ALL_OPS.map((op) => {
|
||||||
|
const isActive = op === activeFilter;
|
||||||
|
const meta = op === "all" ? null : OP_META[op as RegistryEntryOp];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={op}
|
||||||
|
onClick={() => setActiveFilter(op as typeof activeFilter)}
|
||||||
|
style={{
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
border: isActive
|
||||||
|
? `1px solid ${meta?.border ?? "rgba(127,208,255,0.35)"}`
|
||||||
|
: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
background: isActive
|
||||||
|
? (meta?.bg ?? "rgba(74,163,255,0.14)")
|
||||||
|
: "transparent",
|
||||||
|
color: isActive
|
||||||
|
? (meta?.color ?? "#8ed3ff")
|
||||||
|
: "#8b949e",
|
||||||
|
transition: "all 140ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{op === "all" ? "All" : (meta?.label ?? op)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feed */}
|
||||||
|
<div style={feedStyle}>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div style={emptyStateStyle}>
|
||||||
|
<ClipboardList size={36} color="rgba(127,208,255,0.15)" />
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 14, marginTop: 12, fontWeight: 500 }}>
|
||||||
|
No events recorded yet
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||||
|
Import a file, run reasoning, or merge entities to see activity appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((entry) => <EntryRow key={entry.id} entry={entry} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const shellStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "#0d1117",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "20px 24px 16px",
|
||||||
|
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterBarStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "12px 24px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "16px 24px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const entryCardStyle: React.CSSProperties = {
|
||||||
|
padding: "12px 14px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.6), rgba(22,27,34,0.4))",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandBtnStyle: React.CSSProperties = {
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "#8b949e",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailPreStyle: React.CSSProperties = {
|
||||||
|
marginTop: 10,
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "rgba(0,0,0,0.28)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
color: "#79c0ff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
overflowX: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearBtnStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(255,123,114,0.22)",
|
||||||
|
background: "rgba(255,123,114,0.06)",
|
||||||
|
color: "#ff7b72",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStateStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 40,
|
||||||
|
minHeight: 280,
|
||||||
|
};
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { graph } from "../../store/graphStore";
|
||||||
|
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||||
|
import type { GraphSelectedNodeKind } from "./types";
|
||||||
|
|
||||||
|
export type LinkPrediction = {
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
label?: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PathResponse = {
|
||||||
|
path: string[];
|
||||||
|
edge_ids?: string[];
|
||||||
|
total_weight: number;
|
||||||
|
hop_count: number;
|
||||||
|
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||||
|
// FR-1 distance intelligence enrichment
|
||||||
|
semantic_similarity?: number | null;
|
||||||
|
path_coherence_score?: number | null;
|
||||||
|
confidence_decay?: number | null;
|
||||||
|
bottleneck_node?: string | null;
|
||||||
|
alternative_path_count?: number;
|
||||||
|
interpretation?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GraphInspectorPanelProps {
|
||||||
|
nodeId: string;
|
||||||
|
inspectableNodeId?: string | null;
|
||||||
|
selectedNodeKind?: GraphSelectedNodeKind;
|
||||||
|
canActivateFocused?: boolean;
|
||||||
|
focusedUnavailableReason?: string | null;
|
||||||
|
predictions: LinkPrediction[];
|
||||||
|
predictionType: string;
|
||||||
|
onPredictionTypeChange: (value: string) => void;
|
||||||
|
onRunPredictions: () => void;
|
||||||
|
isRunningPredictions?: boolean;
|
||||||
|
pathTargetId: string;
|
||||||
|
onPathTargetChange: (value: string) => void;
|
||||||
|
onTracePath: () => void;
|
||||||
|
pathResult: PathResponse | null;
|
||||||
|
onDownloadProvenance: (format: "json" | "markdown") => void;
|
||||||
|
onFocusNode?: (nodeId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
|
||||||
|
|
||||||
|
function sourceAttribution(properties: Record<string, unknown>) {
|
||||||
|
return PROVENANCE_KEYS
|
||||||
|
.filter((key) => key in properties)
|
||||||
|
.map((key) => ({ key, value: properties[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Path Distance Intelligence Panel ──────────────────────────── */
|
||||||
|
|
||||||
|
const BAND_COLORS: Record<string, string> = {
|
||||||
|
direct: "#3fb950",
|
||||||
|
near: "#79c0ff",
|
||||||
|
"mid-range": "#e3b341",
|
||||||
|
distant: "#ff7b72",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||||
|
const hasMetrics =
|
||||||
|
result.confidence_decay != null ||
|
||||||
|
result.semantic_similarity != null ||
|
||||||
|
result.path_coherence_score != null ||
|
||||||
|
result.bottleneck_node ||
|
||||||
|
result.interpretation;
|
||||||
|
if (!hasMetrics) return null;
|
||||||
|
|
||||||
|
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||||
|
{/* distance band + alt paths */}
|
||||||
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: withAlpha(bandColor, 0.14),
|
||||||
|
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
|
||||||
|
color: bandColor,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{(result.alternative_path_count ?? 0) > 0 && (
|
||||||
|
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* metric grid */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||||
|
{result.confidence_decay != null && (
|
||||||
|
<div style={metricCardStyle}>
|
||||||
|
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...metricValueStyle,
|
||||||
|
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(result.confidence_decay * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div style={metricBarTrackStyle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...metricBarFillStyle,
|
||||||
|
width: `${result.confidence_decay * 100}%`,
|
||||||
|
background:
|
||||||
|
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.semantic_similarity != null && (
|
||||||
|
<div style={metricCardStyle}>
|
||||||
|
<div style={metricLabelStyle}>Semantic Sim.</div>
|
||||||
|
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
|
||||||
|
{(result.semantic_similarity * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div style={metricBarTrackStyle}>
|
||||||
|
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.path_coherence_score != null && (
|
||||||
|
<div style={metricCardStyle}>
|
||||||
|
<div style={metricLabelStyle}>Path Coherence</div>
|
||||||
|
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
|
||||||
|
{(result.path_coherence_score * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.bottleneck_node && (
|
||||||
|
<div style={metricCardStyle}>
|
||||||
|
<div style={metricLabelStyle}>Bottleneck</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...metricValueStyle,
|
||||||
|
color: "#e3b341",
|
||||||
|
fontSize: 11,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
title={result.bottleneck_node}
|
||||||
|
>
|
||||||
|
{getNodeLabel(result.bottleneck_node)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* interpretation */}
|
||||||
|
{result.interpretation && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
background: "rgba(88,166,255,0.06)",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(88,166,255,0.14)",
|
||||||
|
color: "#a0b4cc",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.interpretation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function getNodeLabel(nodeId: string): string {
|
||||||
|
if (!graph.hasNode(nodeId)) return nodeId;
|
||||||
|
const attrs = graph.getNodeAttributes(nodeId) as { label?: string; content?: string };
|
||||||
|
return String(attrs.label ?? attrs.content ?? nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeLabelBetween(sourceId: string, targetId: string, edgeIds?: string[]): string {
|
||||||
|
// Try to find the specific edge from edgeIds first
|
||||||
|
if (edgeIds) {
|
||||||
|
for (const edgeId of edgeIds) {
|
||||||
|
if (graph.hasEdge(edgeId)) {
|
||||||
|
const [src, tgt] = graph.extremities(edgeId);
|
||||||
|
if ((src === sourceId && tgt === targetId) || (src === targetId && tgt === sourceId)) {
|
||||||
|
const attrs = graph.getEdgeAttributes(edgeId) as { edgeType?: string };
|
||||||
|
return attrs.edgeType ?? "→";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: find any edge between the pair
|
||||||
|
if (graph.hasNode(sourceId) && graph.hasNode(targetId)) {
|
||||||
|
let label = "→";
|
||||||
|
graph.forEachEdge(sourceId, targetId, (_edgeId, attrs) => {
|
||||||
|
const edgeAttrs = attrs as { edgeType?: string };
|
||||||
|
if (edgeAttrs.edgeType) label = edgeAttrs.edgeType;
|
||||||
|
});
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
return "→";
|
||||||
|
}
|
||||||
|
|
||||||
|
function PathFlowViz({
|
||||||
|
path,
|
||||||
|
edgeIds,
|
||||||
|
totalWeight,
|
||||||
|
bottleneckNodeId,
|
||||||
|
onFocusNode,
|
||||||
|
}: {
|
||||||
|
path: string[];
|
||||||
|
edgeIds?: string[];
|
||||||
|
totalWeight: number;
|
||||||
|
bottleneckNodeId?: string | null;
|
||||||
|
onFocusNode?: (nodeId: string) => void;
|
||||||
|
}) {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return <div style={emptyTextStyle}>No path found between the selected nodes.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Horizontal scrollable chip flow */}
|
||||||
|
<div style={pathFlowContainerStyle}>
|
||||||
|
{path.map((nodeId, index) => {
|
||||||
|
const label = getNodeLabel(nodeId);
|
||||||
|
const edgeLabel =
|
||||||
|
index < path.length - 1
|
||||||
|
? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${nodeId}-${index}`} style={{ display: "contents" }}>
|
||||||
|
{/* Node chip */}
|
||||||
|
<button
|
||||||
|
onClick={() => onFocusNode?.(nodeId)}
|
||||||
|
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||||
|
style={{
|
||||||
|
...pathNodeChipStyle,
|
||||||
|
cursor: onFocusNode ? "pointer" : "default",
|
||||||
|
...(nodeId === bottleneckNodeId
|
||||||
|
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||||
|
<span style={{ maxWidth: 120, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Edge connector */}
|
||||||
|
{edgeLabel !== null ? (
|
||||||
|
<div style={pathEdgeConnectorStyle}>
|
||||||
|
<div style={{ width: 16, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||||
|
<span style={pathEdgeLabelStyle}>{edgeLabel}</span>
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<div style={{ width: 12, height: 1, background: "rgba(88,166,255,0.3)" }} />
|
||||||
|
<div style={{ width: 0, height: 0, borderTop: "4px solid transparent", borderBottom: "4px solid transparent", borderLeft: "5px solid rgba(88,166,255,0.4)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight badge */}
|
||||||
|
<div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11 }}>Total weight:</span>
|
||||||
|
<span style={{ color: "#79c0ff", fontSize: 12, fontWeight: 700 }}>{totalWeight.toFixed(3)}</span>
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11 }}>·</span>
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11 }}>{path.length} hops</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Panel ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function GraphInspectorPanel({
|
||||||
|
nodeId,
|
||||||
|
inspectableNodeId,
|
||||||
|
selectedNodeKind = "none",
|
||||||
|
canActivateFocused = false,
|
||||||
|
focusedUnavailableReason = null,
|
||||||
|
predictions,
|
||||||
|
predictionType,
|
||||||
|
onPredictionTypeChange,
|
||||||
|
onRunPredictions,
|
||||||
|
isRunningPredictions = false,
|
||||||
|
pathTargetId,
|
||||||
|
onPathTargetChange,
|
||||||
|
onTracePath,
|
||||||
|
pathResult,
|
||||||
|
onDownloadProvenance,
|
||||||
|
onFocusNode,
|
||||||
|
}: GraphInspectorPanelProps) {
|
||||||
|
if (!nodeId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||||
|
</div>
|
||||||
|
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||||
|
Search for a node or click one in the canvas to inspect its properties.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
|
||||||
|
const directlyInspectable = graph.hasNode(nodeId);
|
||||||
|
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||||
|
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||||
|
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
|
||||||
|
|
||||||
|
if (!effectiveNodeId) {
|
||||||
|
return (
|
||||||
|
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||||
|
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||||
|
{nodeId}
|
||||||
|
</h3>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||||
|
</div>
|
||||||
|
<div style={groupedSelectionNoticeStyle}>
|
||||||
|
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||||
|
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
{canActivateFocused
|
||||||
|
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||||
|
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = graph.getNodeAttributes(effectiveNodeId) as {
|
||||||
|
color?: string;
|
||||||
|
content?: string;
|
||||||
|
label?: string;
|
||||||
|
nodeType?: string;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const properties = attributes?.properties ?? {};
|
||||||
|
const attribution = sourceAttribution(properties);
|
||||||
|
const accentColor = attributes?.color || "#58a6ff";
|
||||||
|
const propertyEntries = Object.entries(properties).filter(
|
||||||
|
([key]) =>
|
||||||
|
!["x","y","valid_from","valid_until","content","source","source_url","pmid","pmids","evidence","provenance","confidence"].includes(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||||
|
{/* Node identity */}
|
||||||
|
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||||
|
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||||
|
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||||
|
{String(attributes?.label ?? effectiveNodeId)}
|
||||||
|
</h3>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||||
|
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||||
|
</div>
|
||||||
|
{groupedDisplaySelection ? (
|
||||||
|
<div style={groupedSelectionNoticeStyle}>
|
||||||
|
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||||
|
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
{canActivateFocused
|
||||||
|
? `Canonical node available: ${effectiveNodeId}`
|
||||||
|
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
||||||
|
{attributes?.valid_from || attributes?.valid_until ? (
|
||||||
|
<span style={subtleChipStyle}>temporal</span>
|
||||||
|
) : null}
|
||||||
|
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
||||||
|
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temporal bounds */}
|
||||||
|
{(attributes?.valid_from || attributes?.valid_until) ? (
|
||||||
|
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
|
||||||
|
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||||
|
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Actions</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||||
|
onClick={onRunPredictions}
|
||||||
|
disabled={isRunningPredictions || !actionNodeId}
|
||||||
|
>
|
||||||
|
{isRunningPredictions ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||||
|
) : null}
|
||||||
|
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||||
|
Provenance JSON
|
||||||
|
</button>
|
||||||
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||||
|
Provenance MD
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={predictionType}
|
||||||
|
onChange={(event) => onPredictionTypeChange(event.target.value)}
|
||||||
|
placeholder="Optional candidate type filter, e.g. disease"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Trace Path */}
|
||||||
|
<section style={sectionStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Trace Path</div>
|
||||||
|
<input
|
||||||
|
value={pathTargetId}
|
||||||
|
onChange={(event) => onPathTargetChange(event.target.value)}
|
||||||
|
placeholder="Target node ID"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||||
|
|
||||||
|
{pathResult?.path?.length ? (
|
||||||
|
<>
|
||||||
|
<PathFlowViz
|
||||||
|
path={pathResult.path}
|
||||||
|
edgeIds={pathResult.edge_ids}
|
||||||
|
totalWeight={pathResult.total_weight}
|
||||||
|
bottleneckNodeId={pathResult.bottleneck_node}
|
||||||
|
onFocusNode={onFocusNode}
|
||||||
|
/>
|
||||||
|
<PathDistanceIntelPanel result={pathResult} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={emptyTextStyle}>
|
||||||
|
Choose a target or click a candidate prediction to prepare a path trace.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Candidate Links */}
|
||||||
|
<details className="node-panel-collapse" open={predictions.length > 0}>
|
||||||
|
<summary className="node-panel-summary">Candidate Links</summary>
|
||||||
|
<div className="node-panel-body">
|
||||||
|
{predictions.length > 0 ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{predictions.map((prediction) => (
|
||||||
|
<button
|
||||||
|
key={`${prediction.target}-${prediction.type}`}
|
||||||
|
style={predictionCardStyle}
|
||||||
|
onClick={() => onPathTargetChange(prediction.target)}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
padding: "2px 7px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "rgba(88,166,255,0.12)",
|
||||||
|
border: "1px solid rgba(88,166,255,0.22)",
|
||||||
|
color: "#58a6ff",
|
||||||
|
}}>
|
||||||
|
{(prediction.score * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : isRunningPredictions ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: 8, color: "#8b949e", fontSize: 12 }}>
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
<span>Computing candidate links…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Source Attribution */}
|
||||||
|
<details className="node-panel-collapse">
|
||||||
|
<summary className="node-panel-summary">Source Attribution</summary>
|
||||||
|
<div className="node-panel-body">
|
||||||
|
{attribution.length ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{attribution.map(({ key, value }) => (
|
||||||
|
<div key={key} style={propertyCardStyle}>
|
||||||
|
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||||
|
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Properties */}
|
||||||
|
<details className="node-panel-collapse">
|
||||||
|
<summary className="node-panel-summary">Properties</summary>
|
||||||
|
<div className="node-panel-body">
|
||||||
|
{propertyEntries.length ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{propertyEntries.map(([key, value]) => (
|
||||||
|
<div key={key} style={propertyCardStyle}>
|
||||||
|
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||||
|
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
background: "rgba(4, 10, 18, 0.5)",
|
||||||
|
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||||
|
color: "#edf5ff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "11px 13px",
|
||||||
|
fontSize: 13,
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||||
|
marginTop: 12,
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "rgba(88,166,255,0.08)",
|
||||||
|
border: "1px solid rgba(88,166,255,0.2)",
|
||||||
|
borderRadius: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionButtonStyle: CSSProperties = {
|
||||||
|
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||||
|
color: "#fff",
|
||||||
|
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "9px 12px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 12,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryActionButtonStyle: CSSProperties = {
|
||||||
|
...actionButtonStyle,
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
color: "#c6d4e3",
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
const predictionCardStyle: CSSProperties = {
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "rgba(88, 166, 255, 0.08)",
|
||||||
|
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
const propertyCardStyle: CSSProperties = {
|
||||||
|
background: "rgba(0, 0, 0, 0.2)",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyTextStyle: CSSProperties = {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtleChipStyle: CSSProperties = {
|
||||||
|
background: "rgba(255, 255, 255, 0.04)",
|
||||||
|
color: "#9fb6d2",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 11,
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
padding: 14,
|
||||||
|
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
borderRadius: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitleStyle: CSSProperties = {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathFlowContainerStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
rowGap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathNodeChipStyle: CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "5px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(88,166,255,0.1)",
|
||||||
|
border: "1px solid rgba(88,166,255,0.22)",
|
||||||
|
color: "#e6edf3",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
maxWidth: 160,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathNodeIndexStyle: CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(88,166,255,0.22)",
|
||||||
|
color: "#79c0ff",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathEdgeConnectorStyle: CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathEdgeLabelStyle: CSSProperties = {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#6a7f97",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
maxWidth: 70,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricCardStyle: CSSProperties = {
|
||||||
|
background: "rgba(0,0,0,0.18)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 10px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.05)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricLabelStyle: CSSProperties = {
|
||||||
|
color: "rgba(88,166,255,0.65)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricValueStyle: CSSProperties = {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#e6edf3",
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricBarTrackStyle: CSSProperties = {
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(255,255,255,0.07)",
|
||||||
|
overflow: "hidden",
|
||||||
|
marginTop: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricBarFillStyle: CSSProperties = {
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
transition: "width 300ms ease",
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ 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,
|
||||||
@@ -41,6 +42,7 @@ 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:");
|
||||||
|
|
||||||
@@ -67,6 +69,10 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +119,10 @@ 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]);
|
||||||
|
|
||||||
@@ -447,9 +457,16 @@ 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}
|
||||||
@@ -33,6 +33,8 @@ 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 = {
|
||||||
@@ -137,6 +139,10 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +431,10 @@ 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]);
|
||||||
@@ -553,6 +563,19 @@ 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
|
||||||
@@ -629,8 +652,8 @@ export function GraphWorkspaceShell() {
|
|||||||
<div className="graph-toggle-cluster">
|
<div className="graph-toggle-cluster">
|
||||||
{selectedNodeId ? (
|
{selectedNodeId ? (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
<button onClick={() => 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("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
<button onClick={() => requestViewMode("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,6 +24,8 @@ 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) => {
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { GraphBehavior } from "./types";
|
||||||
|
|
||||||
|
export const focusCameraBehavior: GraphBehavior = {
|
||||||
|
id: "focus-camera",
|
||||||
|
attach: () => {},
|
||||||
|
detach: () => {},
|
||||||
|
performAction: (context, action) => {
|
||||||
|
if (action.type === "focusNode") {
|
||||||
|
context.focusNodeInView(action.nodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "centerSelection") {
|
||||||
|
context.centerSelectionInView(action.nodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "centerGroupedSelection") {
|
||||||
|
context.centerGroupedSelectionInView(action.nodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { GraphBehavior } from "./types";
|
||||||
|
|
||||||
|
const SWEEP_TICKS = 6;
|
||||||
|
const SWEEP_INTERVAL_MS = 60;
|
||||||
|
|
||||||
|
export function createPathHighlightBehavior(): GraphBehavior {
|
||||||
|
let lastPathSignature = "";
|
||||||
|
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let sweepGeneration = 0;
|
||||||
|
|
||||||
|
function cancelSweep() {
|
||||||
|
sweepGeneration++;
|
||||||
|
if (sweepTimer !== null) {
|
||||||
|
clearTimeout(sweepTimer);
|
||||||
|
sweepTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSweep(sigma: { refresh: () => void }, tick: number, gen: number) {
|
||||||
|
if (tick >= SWEEP_TICKS) return;
|
||||||
|
sweepTimer = setTimeout(() => {
|
||||||
|
if (gen !== sweepGeneration) return;
|
||||||
|
sigma.refresh();
|
||||||
|
scheduleSweep(sigma, tick + 1, gen);
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "path-highlight",
|
||||||
|
attach: () => {},
|
||||||
|
detach: (context) => {
|
||||||
|
cancelSweep();
|
||||||
|
lastPathSignature = "";
|
||||||
|
context.sigma.refresh();
|
||||||
|
},
|
||||||
|
onStateChange: (context, interactionState) => {
|
||||||
|
const nextPathSignature = interactionState.activePath.join("::");
|
||||||
|
if (nextPathSignature === lastPathSignature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPathSignature = nextPathSignature;
|
||||||
|
cancelSweep();
|
||||||
|
context.sigma.refresh();
|
||||||
|
|
||||||
|
// Animate intermediate nodes lighting up sequentially
|
||||||
|
if (interactionState.activePath.length > 2) {
|
||||||
|
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { GraphBehavior } from "./types";
|
||||||
|
|
||||||
|
export function createSearchFocusBehavior(): GraphBehavior {
|
||||||
|
let lastSelectedNodeId = "";
|
||||||
|
let lastViewMode = "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "search-focus",
|
||||||
|
attach: () => {},
|
||||||
|
detach: () => {
|
||||||
|
lastSelectedNodeId = "";
|
||||||
|
lastViewMode = "";
|
||||||
|
},
|
||||||
|
onStateChange: (context, interactionState) => {
|
||||||
|
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||||
|
const nextViewMode = interactionState.viewMode;
|
||||||
|
if (nextViewMode !== lastViewMode) {
|
||||||
|
lastViewMode = nextViewMode;
|
||||||
|
lastSelectedNodeId = nextSelectedNodeId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||||
|
lastSelectedNodeId = nextSelectedNodeId;
|
||||||
|
lastViewMode = nextViewMode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSelectedNodeId = nextSelectedNodeId;
|
||||||
|
lastViewMode = nextViewMode;
|
||||||
|
context.dispatchAction({
|
||||||
|
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||||
|
nodeId: nextSelectedNodeId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
|||||||
|
|
||||||
export type GraphBehaviorActionRequest =
|
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;
|
||||||
@@ -17,6 +19,8 @@ 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,7 +1,8 @@
|
|||||||
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: "focused" | "full" | null = null;
|
let lastViewMode: GraphViewMode | null = null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "view-mode-switch",
|
id: "view-mode-switch",
|
||||||
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastViewMode = interactionState.viewMode;
|
lastViewMode = interactionState.viewMode;
|
||||||
|
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||||
|
|
||||||
if (interactionState.focusedNodeId) {
|
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ 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: {
|
||||||
@@ -190,6 +191,35 @@ 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;
|
||||||
@@ -265,16 +295,16 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
],
|
],
|
||||||
overview: {
|
overview: {
|
||||||
nodeBase: "#0B1320",
|
nodeBase: "#0B1320",
|
||||||
nodeCore: "#435D7A",
|
nodeCore: "#5A7A9E",
|
||||||
nodeMuted: "#121927",
|
nodeMuted: "#121927",
|
||||||
nodeBorder: "#64758C",
|
nodeBorder: "#7A92AE",
|
||||||
nodeTintMix: 0.03,
|
nodeTintMix: 0.14,
|
||||||
nodeCoreMix: 0.52,
|
nodeCoreMix: 0.72,
|
||||||
nodeShellAlpha: 0.97,
|
nodeShellAlpha: 0.97,
|
||||||
nodeCoreAlpha: 1,
|
nodeCoreAlpha: 1,
|
||||||
edgeBackbone: "rgba(83, 111, 148, 0.04)",
|
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||||
edgeStructure: "rgba(72, 90, 118, 0.009)",
|
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||||
edgeInspection: "rgba(98, 120, 148, 0.026)",
|
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
selected: "#F2D288",
|
selected: "#F2D288",
|
||||||
@@ -285,12 +315,12 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
inferred: "#D07B4D",
|
inferred: "#D07B4D",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
fallback: "rgba(96, 112, 136, 0.1)",
|
fallback: "rgba(96, 112, 136, 0.18)",
|
||||||
nodeAlpha: 0.085,
|
nodeAlpha: 0.12,
|
||||||
edgeOverview: "rgba(82, 100, 124, 0.009)",
|
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||||
edgeStructure: "rgba(92, 112, 138, 0.02)",
|
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||||
edgeInspection: "rgba(124, 148, 176, 0.066)",
|
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||||
edgeFocus: "rgba(160, 186, 218, 0.16)",
|
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
canvas: "#07101A",
|
canvas: "#07101A",
|
||||||
@@ -305,43 +335,43 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
zoomTiers: {
|
zoomTiers: {
|
||||||
overview: {
|
overview: {
|
||||||
maxRatio: Number.POSITIVE_INFINITY,
|
maxRatio: Number.POSITIVE_INFINITY,
|
||||||
nodeScale: 0.66,
|
nodeScale: 0.72,
|
||||||
labelThreshold: 0.985,
|
labelThreshold: 0.995,
|
||||||
labelBudget: 10,
|
labelBudget: 4,
|
||||||
edgePriorityThreshold: 0.72,
|
edgePriorityThreshold: 0.72,
|
||||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||||
edgeSizeScale: 0.34,
|
edgeSizeScale: 0.62,
|
||||||
showBadges: false,
|
showBadges: false,
|
||||||
showCurves: false,
|
showCurves: false,
|
||||||
showContextualArrows: false,
|
showContextualArrows: false,
|
||||||
},
|
},
|
||||||
structure: {
|
structure: {
|
||||||
maxRatio: 1.2,
|
maxRatio: 1.2,
|
||||||
nodeScale: 0.98,
|
nodeScale: 0.94,
|
||||||
labelThreshold: 0.88,
|
labelThreshold: 0.93,
|
||||||
labelBudget: 36,
|
labelBudget: 18,
|
||||||
edgePriorityThreshold: 0.4,
|
edgePriorityThreshold: 0.4,
|
||||||
arrowPriorityThreshold: 0.75,
|
arrowPriorityThreshold: 0.75,
|
||||||
edgeSizeScale: 0.92,
|
edgeSizeScale: 0.92,
|
||||||
showBadges: true,
|
showBadges: false,
|
||||||
showCurves: true,
|
showCurves: false,
|
||||||
showContextualArrows: true,
|
showContextualArrows: false,
|
||||||
},
|
},
|
||||||
inspection: {
|
inspection: {
|
||||||
maxRatio: 0.5,
|
maxRatio: 0.5,
|
||||||
nodeScale: 1,
|
nodeScale: 1,
|
||||||
labelThreshold: 0.7,
|
labelThreshold: 0.8,
|
||||||
labelBudget: 80,
|
labelBudget: 40,
|
||||||
edgePriorityThreshold: 0,
|
edgePriorityThreshold: 0,
|
||||||
arrowPriorityThreshold: 0.58,
|
arrowPriorityThreshold: 0.45,
|
||||||
edgeSizeScale: 1.04,
|
edgeSizeScale: 1.18,
|
||||||
showBadges: true,
|
showBadges: true,
|
||||||
showCurves: true,
|
showCurves: true,
|
||||||
showContextualArrows: true,
|
showContextualArrows: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
forceVisibleStates: ["hovered", "selected", "path"],
|
||||||
policies: {
|
policies: {
|
||||||
none: { minZoomTier: "inspection" },
|
none: { minZoomTier: "inspection" },
|
||||||
priority: { minZoomTier: "overview" },
|
priority: { minZoomTier: "overview" },
|
||||||
@@ -391,7 +421,7 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
backgroundScale: 0.52,
|
backgroundScale: 0.52,
|
||||||
mutedAlpha: 0.08,
|
mutedAlpha: 0.16,
|
||||||
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 },
|
||||||
@@ -399,18 +429,18 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||||
hovered: { color: "hovered", sizeMultiplier: 1.18, minSize: 12.5, forceLabel: true, zIndex: 4, borderBoost: 0.22 },
|
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||||
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
|
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||||
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
|
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||||
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||||
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||||
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||||
},
|
},
|
||||||
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: "structure" },
|
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
|
||||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
|
||||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||||
},
|
},
|
||||||
selectedRing: {
|
selectedRing: {
|
||||||
@@ -437,14 +467,14 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
},
|
},
|
||||||
edges: {
|
edges: {
|
||||||
states: {
|
states: {
|
||||||
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||||
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
|
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
|
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||||
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
|
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||||
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
|
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||||
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||||
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
|
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||||
@@ -473,6 +503,35 @@ 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",
|
||||||
@@ -530,7 +589,7 @@ export const GRAPH_THEME: GraphTheme = {
|
|||||||
maxGroups: 8,
|
maxGroups: 8,
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
enabledInDev: import.meta.env.DEV,
|
enabledInDev: IS_DEV,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -42,6 +42,7 @@ 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,
|
||||||
@@ -82,6 +83,11 @@ 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,
|
||||||
@@ -97,6 +103,34 @@ 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) => (
|
||||||
@@ -159,6 +193,16 @@ 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,6 +6,7 @@ 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,
|
||||||
@@ -31,6 +32,8 @@ 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 }
|
||||||
@@ -77,6 +80,7 @@ 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,6 +5,8 @@ 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,
|
||||||
@@ -22,6 +24,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,13 @@ 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[];
|
||||||
@@ -56,6 +66,8 @@ 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";
|
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||||
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,6 +12,7 @@ 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;
|
||||||
@@ -31,6 +32,28 @@ 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"
|
||||||
@@ -245,6 +268,10 @@ 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 {
|
||||||
@@ -260,6 +287,12 @@ 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 {
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { logEvent } from "../../store/registryStore";
|
||||||
|
|
||||||
const THEME_CSS = `
|
const THEME_CSS = `
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
@@ -107,6 +108,11 @@ export function ImportExportWorkspace() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
|
||||||
|
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
|
||||||
|
file: file.name,
|
||||||
|
nodesImported: data.nodes_imported,
|
||||||
|
edgesImported: data.edges_imported,
|
||||||
|
});
|
||||||
setFile(null);
|
setFile(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast("error", err.message || "An error occurred during import");
|
showToast("error", err.message || "An error occurred during import");
|
||||||
@@ -142,6 +148,7 @@ export function ImportExportWorkspace() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|
||||||
showToast("success", "Export complete! Your download should begin shortly.");
|
showToast("success", "Export complete! Your download should begin shortly.");
|
||||||
|
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showToast("error", err.message || "An error occurred during export");
|
showToast("error", err.message || "An error occurred during export");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* src/workspaces/ManageWorkspace/KGOverviewTab.tsx
|
||||||
|
*
|
||||||
|
* Quick-view dashboard for the Knowledge Graph: node/edge counts,
|
||||||
|
* type distributions, and top connected nodes.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Network, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface KGStats {
|
||||||
|
node_count: number;
|
||||||
|
edge_count: number;
|
||||||
|
node_types?: Record<string, number>;
|
||||||
|
edge_types?: Record<string, number>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeListResponse {
|
||||||
|
nodes: NodeItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBar({ label, count, total, color }: { label: string; count: number; total: number; color: string }) {
|
||||||
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "5px 0" }}>
|
||||||
|
<div style={{ width: 120, flexShrink: 0, color: "#c6d4e3", fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={label}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, height: 6, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: color,
|
||||||
|
transition: "width 400ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ width: 52, textAlign: "right", flexShrink: 0, display: "flex", gap: 6, justifyContent: "flex-end" }}>
|
||||||
|
<span style={{ color: "#8b949e", fontSize: 11 }}>{count.toLocaleString()}</span>
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11 }}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COLORS = ["#3E79F2", "#149287", "#2F9F61", "#555FD6", "#8A56D8", "#B65473", "#C9922E", "#4aa3ff", "#f2b66d"];
|
||||||
|
const EDGE_COLORS = ["#4cc38a", "#79c0ff", "#d2a8ff", "#f2b66d", "#ff7b72", "#58a6ff", "#4aa3ff", "#8A56D8"];
|
||||||
|
|
||||||
|
function buildTypeMap(nodes: NodeItem[], key: keyof NodeItem): Record<string, number> {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
for (const node of nodes) {
|
||||||
|
const val = String(node[key] ?? "unknown");
|
||||||
|
map[val] = (map[val] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KGOverviewTab() {
|
||||||
|
const [stats, setStats] = useState<KGStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [topNodes, setTopNodes] = useState<{ node: NodeItem; neighborCount: number }[]>([]);
|
||||||
|
const [nodeTypeMap, setNodeTypeMap] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const fetchOverview = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [statsRes, nodesRes] = await Promise.all([
|
||||||
|
fetch("/api/graph/stats"),
|
||||||
|
fetch("/api/graph/nodes?limit=500"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const statsData: KGStats = await statsRes.json();
|
||||||
|
setStats(statsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodesRes.ok) {
|
||||||
|
const nodesData: NodeListResponse = await nodesRes.json();
|
||||||
|
const nodes = nodesData.nodes ?? [];
|
||||||
|
setNodeTypeMap(buildTypeMap(nodes, "type"));
|
||||||
|
|
||||||
|
// Simulate neighbor counts via edges fetch for top-N
|
||||||
|
const edgesRes = await fetch("/api/graph/edges?limit=2000");
|
||||||
|
if (edgesRes.ok) {
|
||||||
|
const edgesData = await edgesRes.json();
|
||||||
|
const edges: { source: string; target: string }[] = edgesData.edges ?? [];
|
||||||
|
const degreeMap: Record<string, number> = {};
|
||||||
|
for (const edge of edges) {
|
||||||
|
degreeMap[edge.source] = (degreeMap[edge.source] ?? 0) + 1;
|
||||||
|
degreeMap[edge.target] = (degreeMap[edge.target] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
const sorted = nodes
|
||||||
|
.map((n) => ({ node: n, neighborCount: degreeMap[n.id] ?? 0 }))
|
||||||
|
.sort((a, b) => b.neighborCount - a.neighborCount)
|
||||||
|
.slice(0, 10);
|
||||||
|
setTopNodes(sorted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load graph overview. Ensure the server is running.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchOverview();
|
||||||
|
}, [fetchOverview]);
|
||||||
|
|
||||||
|
const nodeTypeEntries = Object.entries(nodeTypeMap).sort((a, b) => b[1] - a[1]);
|
||||||
|
const edgeTypeEntries = stats?.edge_types
|
||||||
|
? Object.entries(stats.edge_types).sort((a, b) => b[1] - a[1])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const totalNodes = stats?.node_count ?? 0;
|
||||||
|
const totalEdges = stats?.edge_count ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<Network size={18} color="#4aa3ff" />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>KG Overview</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>Quick view of the Knowledge Graph structure and health</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => void fetchOverview()} disabled={loading} style={refreshBtnStyle}>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div style={{ margin: "16px 24px", padding: "10px 14px", borderRadius: 10, background: "rgba(255,123,114,0.08)", border: "1px solid rgba(255,123,114,0.2)", color: "#ff7b72", fontSize: 13 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={scrollBodyStyle}>
|
||||||
|
{/* Stats chips */}
|
||||||
|
<div style={statsRowStyle}>
|
||||||
|
{[
|
||||||
|
{ label: "Nodes", value: totalNodes.toLocaleString(), color: "#4aa3ff", sub: `${nodeTypeEntries.length} types` },
|
||||||
|
{ label: "Edges", value: totalEdges.toLocaleString(), color: "#4cc38a", sub: `${edgeTypeEntries.length} relationship types` },
|
||||||
|
{ label: "Density", value: totalNodes > 1 ? ((totalEdges / (totalNodes * (totalNodes - 1))) * 100).toFixed(3) + "%" : "—", color: "#d2a8ff", sub: "graph density" },
|
||||||
|
].map(({ label, value, color, sub }) => (
|
||||||
|
<div key={label} style={statCardStyle}>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 }}>{label}</div>
|
||||||
|
<div style={{ color, fontSize: 28, fontWeight: 800, letterSpacing: "-0.04em", lineHeight: 1 }}>{loading ? "—" : value}</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 11, marginTop: 4 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type breakdowns */}
|
||||||
|
<div style={sectionRowStyle}>
|
||||||
|
{/* Node types */}
|
||||||
|
<div style={breakdownCardStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Node Type Breakdown</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={skeletonWrapStyle}>
|
||||||
|
{[80, 65, 45, 35, 25].map((w, i) => (
|
||||||
|
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : nodeTypeEntries.length === 0 ? (
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12 }}>No data — load the graph first.</div>
|
||||||
|
) : (
|
||||||
|
nodeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||||
|
<TypeBar key={type} label={type} count={count} total={totalNodes || 1} color={NODE_COLORS[i % NODE_COLORS.length]} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edge types */}
|
||||||
|
<div style={breakdownCardStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Edge Type Breakdown</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={skeletonWrapStyle}>
|
||||||
|
{[70, 55, 48, 30, 20].map((w, i) => (
|
||||||
|
<div key={i} style={{ ...skeletonBarStyle, width: `${w}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : edgeTypeEntries.length === 0 ? (
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12 }}>Edge type breakdown requires the stats endpoint to return edge_types.</div>
|
||||||
|
) : (
|
||||||
|
edgeTypeEntries.slice(0, 8).map(([type, count], i) => (
|
||||||
|
<TypeBar key={type} label={type} count={count} total={totalEdges || 1} color={EDGE_COLORS[i % EDGE_COLORS.length]} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top connected nodes */}
|
||||||
|
{topNodes.length > 0 ? (
|
||||||
|
<div style={breakdownCardStyle}>
|
||||||
|
<div style={sectionTitleStyle}>Top Connected Nodes (by degree)</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: 8, marginTop: 2 }}>
|
||||||
|
{topNodes.map(({ node, neighborCount }, rank) => (
|
||||||
|
<div key={node.id} style={topNodeRowStyle}>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12, fontWeight: 700, minWidth: 20 }}>#{rank + 1}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: "#e6edf3", fontSize: 13, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{node.content || node.id}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 11 }}>{node.type}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#4aa3ff", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{neighborCount} conn.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const shellStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "#0d1117",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "20px 24px 16px",
|
||||||
|
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshBtnStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(127,208,255,0.16)",
|
||||||
|
background: "rgba(74,163,255,0.08)",
|
||||||
|
color: "#8fa8c6",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollBodyStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "20px 24px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statsRowStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const statCardStyle: React.CSSProperties = {
|
||||||
|
padding: "18px 20px",
|
||||||
|
borderRadius: 16,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.8), rgba(22,27,34,0.5))",
|
||||||
|
border: "1px solid rgba(127,208,255,0.1)",
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionRowStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdownCardStyle: React.CSSProperties = {
|
||||||
|
padding: "16px 18px",
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "linear-gradient(135deg, rgba(13,17,23,0.7), rgba(22,27,34,0.4))",
|
||||||
|
border: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTitleStyle: React.CSSProperties = {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const topNodeRowStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "rgba(255,255,255,0.025)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const skeletonWrapStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const skeletonBarStyle: React.CSSProperties = {
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
animation: "skeleton-pulse 1.4s ease-in-out infinite",
|
||||||
|
};
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* src/workspaces/ManageWorkspace/OntologySummaryTab.tsx
|
||||||
|
*
|
||||||
|
* A compact read-only view of all loaded SKOS ConceptSchemes and their
|
||||||
|
* top-level concepts. Clicking a concept deep-links to the Vocabulary Browser.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BookOpen, ChevronRight, ChevronDown, ExternalLink } from "lucide-react";
|
||||||
|
import { useVocabularies, useConceptHierarchy } from "../VocabularyWorkspace/queries";
|
||||||
|
import type { ConceptNode, VocabularyScheme } from "../VocabularyWorkspace/types";
|
||||||
|
|
||||||
|
function countConcepts(nodes: ConceptNode[]): number {
|
||||||
|
return nodes.reduce((acc, node) => {
|
||||||
|
return acc + 1 + countConcepts(node.children ?? []);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConceptRow({
|
||||||
|
concept,
|
||||||
|
depth,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
concept: ConceptNode;
|
||||||
|
depth: number;
|
||||||
|
onSelect: (concept: ConceptNode) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const children = concept.children ?? [];
|
||||||
|
const hasChildren = children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
paddingLeft: 12 + depth * 16,
|
||||||
|
paddingRight: 12,
|
||||||
|
paddingTop: 5,
|
||||||
|
paddingBottom: 5,
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: depth === 0 ? "#c6d4e3" : "#8b949e",
|
||||||
|
fontSize: depth === 0 ? 13 : 12,
|
||||||
|
transition: "background 120ms ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.07)"; }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", padding: 0, display: "flex", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span style={{ width: 12, display: "inline-block" }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
onClick={() => onSelect(concept)}
|
||||||
|
style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{concept.pref_label || concept.uri}
|
||||||
|
</span>
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 10 }}>{children.length}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{expanded && hasChildren
|
||||||
|
? children.map((child) => (
|
||||||
|
<ConceptRow key={child.uri} concept={child} depth={depth + 1} onSelect={onSelect} />
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemePanel({
|
||||||
|
scheme,
|
||||||
|
onSelectConcept,
|
||||||
|
}: {
|
||||||
|
scheme: VocabularyScheme;
|
||||||
|
onSelectConcept: (concept: ConceptNode) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const { data: hierarchy = [], isLoading } = useConceptHierarchy(scheme.uri);
|
||||||
|
const totalConcepts = countConcepts(hierarchy);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={schemeCardStyle}>
|
||||||
|
{/* Scheme header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
style={schemeHeaderStyle}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
{expanded ? <ChevronDown size={14} color="#8b949e" /> : <ChevronRight size={14} color="#8b949e" />}
|
||||||
|
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: "#6a7f97", fontSize: 11 }}>
|
||||||
|
{isLoading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Concept tree */}
|
||||||
|
{expanded ? (
|
||||||
|
<div style={{ paddingTop: 4, paddingBottom: 8 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12 }}>Loading concepts…</div>
|
||||||
|
) : hierarchy.length === 0 ? (
|
||||||
|
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
|
||||||
|
No concepts found in this scheme.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hierarchy.map((concept) => (
|
||||||
|
<ConceptRow key={concept.uri} concept={concept} depth={0} onSelect={onSelectConcept} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OntologySummaryTab({
|
||||||
|
onOpenVocabularyBrowser,
|
||||||
|
}: {
|
||||||
|
onOpenVocabularyBrowser?: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: schemes = [], isLoading } = useVocabularies();
|
||||||
|
const [selectedConcept, setSelectedConcept] = useState<ConceptNode | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={shellStyle}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<BookOpen size={18} color="#d2a8ff" />
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 700 }}>Ontology Summary</div>
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12 }}>
|
||||||
|
{isLoading
|
||||||
|
? "Loading schemes…"
|
||||||
|
: `${schemes.length} vocabulary scheme${schemes.length !== 1 ? "s" : ""} loaded`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onOpenVocabularyBrowser ? (
|
||||||
|
<button onClick={onOpenVocabularyBrowser} style={openBrowserBtnStyle}>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
<span>Open Full Browser</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||||
|
{/* Scheme tree column */}
|
||||||
|
<div style={treeColumnStyle}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{[90, 75, 60].map((w, i) => (
|
||||||
|
<div key={i} style={{ height: 36, borderRadius: 8, background: "rgba(255,255,255,0.04)", width: `${w}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : schemes.length === 0 ? (
|
||||||
|
<div style={emptyStateStyle}>
|
||||||
|
<BookOpen size={32} color="rgba(210,168,255,0.15)" />
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 13, marginTop: 12 }}>No vocabulary schemes loaded</div>
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 240 }}>
|
||||||
|
Import a .ttl or .rdf file via the Vocabulary Browser to see your ontology here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: "12px 8px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{schemes.map((scheme) => (
|
||||||
|
<SchemePanel key={scheme.uri} scheme={scheme} onSelectConcept={setSelectedConcept} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Concept detail panel */}
|
||||||
|
{selectedConcept ? (
|
||||||
|
<div style={detailPanelStyle}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 16 }}>
|
||||||
|
<div style={{ color: "#d2a8ff", fontSize: 11, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||||
|
Concept Detail
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedConcept(null)} style={{ background: "transparent", border: "none", color: "#8b949e", cursor: "pointer", fontSize: 16 }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ color: "#ffffff", fontSize: 18, fontWeight: 800, letterSpacing: "-0.03em", margin: "0 0 6px 0" }}>
|
||||||
|
{selectedConcept.pref_label}
|
||||||
|
</h3>
|
||||||
|
{selectedConcept.notation ? (
|
||||||
|
<div style={{ color: "#8b949e", fontSize: 12, marginBottom: 8 }}>Notation: {selectedConcept.notation}</div>
|
||||||
|
) : null}
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all", marginBottom: 14 }}>
|
||||||
|
{selectedConcept.uri}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedConcept.description ? (
|
||||||
|
<div style={detailSectionStyle}>
|
||||||
|
<div style={detailLabelStyle}>Description</div>
|
||||||
|
<div style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>{selectedConcept.description}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedConcept.alt_labels?.length ? (
|
||||||
|
<div style={detailSectionStyle}>
|
||||||
|
<div style={detailLabelStyle}>Alternative Labels</div>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
|
{selectedConcept.alt_labels.map((label) => (
|
||||||
|
<span key={label} style={altLabelChipStyle}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(selectedConcept.children?.length ?? 0) > 0 ? (
|
||||||
|
<div style={detailSectionStyle}>
|
||||||
|
<div style={detailLabelStyle}>Narrower Concepts ({selectedConcept.children!.length})</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{selectedConcept.children!.slice(0, 8).map((child) => (
|
||||||
|
<div
|
||||||
|
key={child.uri}
|
||||||
|
onClick={() => setSelectedConcept(child)}
|
||||||
|
style={{ color: "#79c0ff", fontSize: 12, cursor: "pointer", padding: "3px 0" }}
|
||||||
|
>
|
||||||
|
→ {child.pref_label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectedConcept.children!.length > 8 ? (
|
||||||
|
<div style={{ color: "#6a7f97", fontSize: 11 }}>+{selectedConcept.children!.length - 8} more</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── styles ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const shellStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "#0d1117",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "20px 24px 16px",
|
||||||
|
borderBottom: "1px solid rgba(88,166,255,0.1)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBrowserBtnStyle: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid rgba(210,168,255,0.22)",
|
||||||
|
background: "rgba(210,168,255,0.08)",
|
||||||
|
color: "#d2a8ff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeColumnStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
borderRight: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemeCardStyle: React.CSSProperties = {
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid rgba(210,168,255,0.1)",
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemeHeaderStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailPanelStyle: React.CSSProperties = {
|
||||||
|
width: 300,
|
||||||
|
padding: "20px",
|
||||||
|
overflowY: "auto",
|
||||||
|
borderLeft: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailSectionStyle: React.CSSProperties = {
|
||||||
|
marginTop: 14,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailLabelStyle: React.CSSProperties = {
|
||||||
|
color: "#8b949e",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.07em",
|
||||||
|
marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const altLabelChipStyle: React.CSSProperties = {
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
color: "#8fa8c6",
|
||||||
|
fontSize: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyStateStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 40,
|
||||||
|
height: "100%",
|
||||||
|
};
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
batchMergeEdges,
|
||||||
|
batchMergeNodes,
|
||||||
|
clearGraph,
|
||||||
|
} from "../src/store/graphStore.ts";
|
||||||
|
import {
|
||||||
|
checkGroupedViewAvailability,
|
||||||
|
resolveDisplayGraph,
|
||||||
|
resolveGroupedDisplayNodeId,
|
||||||
|
resolveGroupedDisplayStateSnapshot,
|
||||||
|
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||||
|
|
||||||
|
function addNode(id: string, semanticGroup = "entity") {
|
||||||
|
batchMergeNodes([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
attributes: {
|
||||||
|
label: id,
|
||||||
|
content: id,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
size: 8,
|
||||||
|
color: "#63E6FF",
|
||||||
|
baseColor: "#63E6FF",
|
||||||
|
nodeType: semanticGroup,
|
||||||
|
semanticGroup,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||||
|
batchMergeEdges([
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
attributes: {
|
||||||
|
edgeType: "related_to",
|
||||||
|
weight,
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
clearGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
clearGraph();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||||
|
addNode("a");
|
||||||
|
addNode("b");
|
||||||
|
addEdge("e1", "a", "b", 1);
|
||||||
|
addEdge("e2", "a", "b", 2);
|
||||||
|
|
||||||
|
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
|
||||||
|
assert.equal(graph.size, 1);
|
||||||
|
|
||||||
|
const edgeId = graph.edges()[0];
|
||||||
|
const attrs = graph.getEdgeAttributes(edgeId) as {
|
||||||
|
isAggregated?: boolean;
|
||||||
|
aggregateCount?: number;
|
||||||
|
rawEdgeIds?: string[];
|
||||||
|
bundleKind?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(attrs.isAggregated, true);
|
||||||
|
assert.equal(attrs.aggregateCount, 2);
|
||||||
|
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
|
||||||
|
assert.equal(attrs.bundleKind, "parallel");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
|
||||||
|
addNode("center");
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
const neighbor = `n${index}`;
|
||||||
|
addNode(neighbor);
|
||||||
|
addEdge(`edge-${index}`, "center", neighbor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
|
||||||
|
aggregationEnabled: false,
|
||||||
|
collapsedNeighborhoodNodeIds: ["center"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(state.selectedRootNodeId, "center");
|
||||||
|
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
|
||||||
|
assert.equal(state.selectedVisibleNeighborIds.length, 9);
|
||||||
|
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
|
||||||
|
const left = ["a1", "a2", "a3", "a4"];
|
||||||
|
const right = ["b1", "b2", "b3", "b4"];
|
||||||
|
|
||||||
|
[...left, ...right].forEach((nodeId, index) => {
|
||||||
|
addNode(nodeId, index < left.length ? "left" : "right");
|
||||||
|
});
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) {
|
||||||
|
addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) {
|
||||||
|
addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||||
|
addEdge("bridge-2", "a2", "b2", 0.1);
|
||||||
|
|
||||||
|
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||||
|
|
||||||
|
assert.equal(state.groupedViewAvailable, true);
|
||||||
|
|
||||||
|
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
|
||||||
|
assert.ok(communityNodes.length >= 2);
|
||||||
|
|
||||||
|
const hasCommunityEdge = graph
|
||||||
|
.edges()
|
||||||
|
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
|
||||||
|
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
|
||||||
|
|
||||||
|
assert.equal(hasCommunityEdge, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
|
||||||
|
addNode("x");
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
|
||||||
|
const left = ["a1", "a2", "a3", "a4"];
|
||||||
|
const right = ["b1", "b2", "b3", "b4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-1", "a1", "b1", 0.1);
|
||||||
|
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||||
|
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
|
||||||
|
assert.ok(communityNodes.length >= 2, "expected community nodes");
|
||||||
|
|
||||||
|
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
|
||||||
|
assert.ok(resolved !== null, "should resolve a1 to a community node");
|
||||||
|
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
|
||||||
|
addNode("p");
|
||||||
|
addNode("q");
|
||||||
|
addEdge("e1", "p", "q");
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
|
||||||
|
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
|
||||||
|
groupedViewAvailable: true,
|
||||||
|
groupedViewReason: null,
|
||||||
|
});
|
||||||
|
assert.equal(state.selectedNodeKind, "none");
|
||||||
|
assert.equal(state.selectedRootNodeId, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
|
||||||
|
const left = ["c1", "c2", "c3", "c4"];
|
||||||
|
const right = ["d1", "d2", "d3", "d4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-c1", "c1", "d1", 0.1);
|
||||||
|
addEdge("bridge-c2", "c2", "d2", 0.1);
|
||||||
|
|
||||||
|
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
|
||||||
|
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
|
||||||
|
groupedViewAvailable: true,
|
||||||
|
groupedViewReason: null,
|
||||||
|
selectedNodeKind: "grouped",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
|
||||||
|
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
|
||||||
|
assert.equal(state.groupedViewAvailable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
|
||||||
|
const result = checkGroupedViewAvailability();
|
||||||
|
assert.equal(result.available, false);
|
||||||
|
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkGroupedViewAvailability returns available when communities exist", () => {
|
||||||
|
const left = ["e1", "e2", "e3", "e4"];
|
||||||
|
const right = ["f1", "f2", "f3", "f4"];
|
||||||
|
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
|
||||||
|
|
||||||
|
let edgeIndex = 0;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
for (let j = 0; j < left.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < right.length; i += 1) {
|
||||||
|
for (let j = 0; j < right.length; j += 1) {
|
||||||
|
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEdge("bridge-e1", "e1", "f1", 0.1);
|
||||||
|
|
||||||
|
const result = checkGroupedViewAvailability();
|
||||||
|
assert.equal(result.available, true);
|
||||||
|
assert.equal(result.reason, null);
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import babel from '@rolldown/plugin-babel'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react({
|
||||||
babel({ presets: [reactCompilerPreset()] })
|
babel: {
|
||||||
|
plugins: ['babel-plugin-react-compiler'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
base: '/',
|
base: '/',
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Semantica × OpenClaw Integration
|
||||||
|
|
||||||
|
Connect [OpenClaw](https://openclaw.ai) — the open-source personal AI agent — to Semantica's full knowledge-graph and decision-intelligence stack.
|
||||||
|
|
||||||
|
Two integration paths are available:
|
||||||
|
|
||||||
|
| Path | When to use |
|
||||||
|
|---|---|
|
||||||
|
| **MCP (recommended)** | OpenClaw Gateway is running; zero extra code needed |
|
||||||
|
| **REST / native tool** | Embedding Semantica directly in a SOUL.md agent config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 1 — MCP Server (recommended)
|
||||||
|
|
||||||
|
### 1. Start the Semantica MCP server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m semantica.mcp_server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add to `mcporter.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "semantica.mcp_server"],
|
||||||
|
"transport": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Restart the OpenClaw Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
All **12 Semantica tools** are now available to any OpenClaw agent:
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `extract_entities` | Named entity recognition from text |
|
||||||
|
| `extract_relations` | Relation / triplet extraction from text |
|
||||||
|
| `record_decision` | Record a decision with causal links |
|
||||||
|
| `query_decisions` | Search recorded decisions |
|
||||||
|
| `find_precedents` | Find past decisions similar to a query |
|
||||||
|
| `get_causal_chain` | Trace cause-effect chains from a node |
|
||||||
|
| `add_entity` | Add a node to the knowledge graph |
|
||||||
|
| `add_relationship` | Add an edge between two nodes |
|
||||||
|
| `run_reasoning` | Forward-chain rules over facts |
|
||||||
|
| `get_graph_analytics` | Centrality, communities, topology stats |
|
||||||
|
| `export_graph` | Export graph (JSON, RDF, GraphML, …) |
|
||||||
|
| `get_graph_summary` | High-level graph overview |
|
||||||
|
|
||||||
|
**3 resources** are also exposed: `semantica://graph/summary`, `semantica://decisions/list`, `semantica://schema/info`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 2 — Native Tool (REST)
|
||||||
|
|
||||||
|
Use `OpenClawKGTool` when you prefer a direct Python integration without the MCP gateway.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install semantica[openclaw] # pulls in 'requests'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from integrations.openclaw import OpenClawKGTool
|
||||||
|
|
||||||
|
tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||||
|
|
||||||
|
# Extract knowledge from text
|
||||||
|
entities = tool.extract_entities("OpenClaw is an open-source AI agent built in Python.")
|
||||||
|
relations = tool.extract_relations("Alice manages the OpenClaw project at Hawksight.")
|
||||||
|
|
||||||
|
# Record and query decisions
|
||||||
|
tool.record_decision("Deploy model v2 to production", context="latency improved by 40%")
|
||||||
|
precedents = tool.find_precedents("roll back production deployment")
|
||||||
|
|
||||||
|
# Graph analytics
|
||||||
|
summary = tool.get_graph_summary()
|
||||||
|
analytics = tool.get_graph_analytics()
|
||||||
|
|
||||||
|
# Export
|
||||||
|
ttl = tool.export_graph(fmt="ttl")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate `mcporter.json` programmatically
|
||||||
|
|
||||||
|
```python
|
||||||
|
from integrations.openclaw import OpenClawMCPConfig
|
||||||
|
|
||||||
|
cfg = OpenClawMCPConfig()
|
||||||
|
print(cfg.to_json()) # → paste into mcporter.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SOUL.md agent snippet
|
||||||
|
|
||||||
|
Add Semantica to any OpenClaw agent by referencing the tool in your `SOUL.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- name: semantica_kg
|
||||||
|
description: >
|
||||||
|
Semantica knowledge-graph tool. Supports entity extraction, decision
|
||||||
|
recording, graph querying, causal chain analysis, reasoning, and
|
||||||
|
multi-format export.
|
||||||
|
endpoint: http://localhost:8000
|
||||||
|
auth: none
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
You have access to `semantica_kg`. Use it to:
|
||||||
|
- Extract entities and relations from any text the user provides.
|
||||||
|
- Record important decisions and retrieve precedents before recommending actions.
|
||||||
|
- Run graph analytics and export results when the user asks for a summary.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- `pip install semantica` (core)
|
||||||
|
- `pip install semantica[openclaw]` (adds `requests` for the REST path)
|
||||||
|
- OpenClaw ≥ latest — [openclaw.ai](https://openclaw.ai)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Semantica × OpenClaw Integration
|
||||||
|
==================================
|
||||||
|
|
||||||
|
First-class integration between the Semantica semantic intelligence stack and
|
||||||
|
`OpenClaw <https://openclaw.ai>`_ — the open-source personal AI agent platform.
|
||||||
|
|
||||||
|
OpenClaw connects to external tools via MCP (Model Context Protocol). This
|
||||||
|
integration exposes the full Semantica MCP surface (12 tools, 3 resources) to
|
||||||
|
any OpenClaw agent and also ships a lightweight ``OpenClawKGTool`` that can be
|
||||||
|
dropped directly into an OpenClaw SOUL.md tool-list as a native tool.
|
||||||
|
|
||||||
|
Public surface
|
||||||
|
--------------
|
||||||
|
OpenClawKGTool — Thin wrapper around the Semantica REST API usable as an
|
||||||
|
OpenClaw native tool (no MCP gateway required)
|
||||||
|
OpenClawMCPConfig — Helper that emits the ``mcporter.json`` snippet needed to
|
||||||
|
wire Semantica's MCP server into an OpenClaw gateway
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
-----------
|
||||||
|
pip install semantica
|
||||||
|
|
||||||
|
>>> from integrations.openclaw import OpenClawKGTool, OpenClawMCPConfig
|
||||||
|
>>> print(OpenClawMCPConfig().to_json()) # paste into mcporter.json
|
||||||
|
>>> tool = OpenClawKGTool(base_url="http://localhost:8000")
|
||||||
|
>>> result = tool.extract("OpenClaw is an open-source AI agent framework.")
|
||||||
|
|
||||||
|
MCP quick start
|
||||||
|
---------------
|
||||||
|
Run the Semantica MCP server once::
|
||||||
|
|
||||||
|
python -m semantica.mcp_server
|
||||||
|
|
||||||
|
Then add the printed config snippet to your OpenClaw ``mcporter.json`` and
|
||||||
|
restart the OpenClaw Gateway::
|
||||||
|
|
||||||
|
openclaw gateway restart
|
||||||
|
|
||||||
|
All 12 Semantica tools are then available as native OpenClaw agent tools.
|
||||||
|
|
||||||
|
Compatibility
|
||||||
|
-------------
|
||||||
|
Requires ``semantica >= 0.3.0``. The MCP path requires ``python >= 3.8`` and
|
||||||
|
a running ``semantica.mcp_server`` instance. The REST path requires a running
|
||||||
|
``semantica.server`` instance (``python -m semantica.server``, port 8000 by
|
||||||
|
default).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .mcp_tool import OpenClawKGTool, OpenClawMCPConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OpenClawKGTool",
|
||||||
|
"OpenClawMCPConfig",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
OpenClaw ↔ Semantica bridge
|
||||||
|
============================
|
||||||
|
|
||||||
|
Two integration paths:
|
||||||
|
|
||||||
|
1. **MCP (recommended)** — ``OpenClawMCPConfig`` emits the ``mcporter.json``
|
||||||
|
snippet that wires Semantica's MCP server into the OpenClaw Gateway.
|
||||||
|
All 12 Semantica MCP tools become native OpenClaw agent tools with no
|
||||||
|
extra code.
|
||||||
|
|
||||||
|
2. **REST** — ``OpenClawKGTool`` is a plain Python class that calls the
|
||||||
|
Semantica REST API (port 8000) and can be registered as an OpenClaw
|
||||||
|
native tool via SOUL.md ``tools:`` entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MCP config helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OpenClawMCPConfig:
|
||||||
|
"""
|
||||||
|
Generates the ``mcporter.json`` entry needed to connect Semantica's MCP
|
||||||
|
server to the OpenClaw Gateway.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
server_command:
|
||||||
|
Shell command used to launch the Semantica MCP server.
|
||||||
|
Defaults to ``"python -m semantica.mcp_server"``.
|
||||||
|
transport:
|
||||||
|
MCP transport protocol. OpenClaw supports ``"stdio"`` (default)
|
||||||
|
and ``"sse"``.
|
||||||
|
name:
|
||||||
|
Key used in ``mcporter.json``. Defaults to ``"semantica"``.
|
||||||
|
|
||||||
|
Example
|
||||||
|
-------
|
||||||
|
>>> cfg = OpenClawMCPConfig()
|
||||||
|
>>> print(cfg.to_json())
|
||||||
|
# → paste into ~/.openclaw/mcporter.json, then:
|
||||||
|
# → openclaw gateway restart
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_command: str = "python -m semantica.mcp_server",
|
||||||
|
transport: str = "stdio",
|
||||||
|
name: str = "semantica",
|
||||||
|
) -> None:
|
||||||
|
self.server_command = server_command
|
||||||
|
self.transport = transport
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Return the config as a plain dict."""
|
||||||
|
parts = self.server_command.split()
|
||||||
|
return {
|
||||||
|
"mcpServers": {
|
||||||
|
self.name: {
|
||||||
|
"command": parts[0],
|
||||||
|
"args": parts[1:],
|
||||||
|
"transport": self.transport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_json(self, indent: int = 2) -> str:
|
||||||
|
"""Return the config as a JSON string."""
|
||||||
|
return json.dumps(self.to_dict(), indent=indent)
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"OpenClawMCPConfig(name={self.name!r}, transport={self.transport!r})"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REST-based native tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OpenClawKGTool:
|
||||||
|
"""
|
||||||
|
A Semantica knowledge-graph tool callable from an OpenClaw agent.
|
||||||
|
|
||||||
|
Wraps the Semantica REST API so that an OpenClaw agent configured with
|
||||||
|
this tool (via SOUL.md ``tools:`` entries or programmatic registration)
|
||||||
|
can extract entities, record decisions, query the graph, and more —
|
||||||
|
without requiring the MCP gateway.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
base_url:
|
||||||
|
Base URL of the running Semantica REST server.
|
||||||
|
Defaults to ``"http://localhost:8000"``.
|
||||||
|
timeout:
|
||||||
|
Request timeout in seconds. Defaults to ``30``.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
``requests`` is used for HTTP calls. It is listed as an optional
|
||||||
|
dependency under ``semantica[openclaw]``; install it with::
|
||||||
|
|
||||||
|
pip install semantica[openclaw]
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOOL_NAME = "semantica_kg"
|
||||||
|
TOOL_DESCRIPTION = (
|
||||||
|
"Semantica knowledge-graph tool. "
|
||||||
|
"Supports entity extraction, decision recording, graph querying, "
|
||||||
|
"causal chain analysis, reasoning, and multi-format export."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = "http://localhost:8000", timeout: int = 30) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self._session: Any = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_session(self) -> Any:
|
||||||
|
if self._session is None:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
self._session = requests.Session()
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"The 'requests' package is required for OpenClawKGTool. "
|
||||||
|
"Install it with: pip install semantica[openclaw]"
|
||||||
|
) from exc
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
session = self._get_session()
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
response = session.post(url, json=payload, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
session = self._get_session()
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
response = session.get(url, params=params or {}, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Extraction
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract(self, text: str) -> Dict[str, Any]:
|
||||||
|
"""Extract entities and relations from *text*."""
|
||||||
|
return self._post("/extract", {"text": text})
|
||||||
|
|
||||||
|
def extract_entities(self, text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Return only the entity list from *text*."""
|
||||||
|
result = self.extract(text)
|
||||||
|
return result.get("entities", [])
|
||||||
|
|
||||||
|
def extract_relations(self, text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Return only the relation list from *text*."""
|
||||||
|
result = self.extract(text)
|
||||||
|
return result.get("relations", [])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Graph mutation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_entity(self, label: str, entity_type: str = "Entity", **properties: Any) -> Dict[str, Any]:
|
||||||
|
"""Add a node to the knowledge graph."""
|
||||||
|
return self._post("/entities", {"label": label, "type": entity_type, **properties})
|
||||||
|
|
||||||
|
def add_relationship(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
target: str,
|
||||||
|
relation_type: str,
|
||||||
|
**properties: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Add an edge between *source* and *target*."""
|
||||||
|
return self._post(
|
||||||
|
"/relationships",
|
||||||
|
{"source": source, "target": target, "type": relation_type, **properties},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Decisions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def record_decision(
|
||||||
|
self,
|
||||||
|
decision_text: str,
|
||||||
|
context: Optional[str] = None,
|
||||||
|
**metadata: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Record a decision in the graph."""
|
||||||
|
payload: Dict[str, Any] = {"decision": decision_text}
|
||||||
|
if context:
|
||||||
|
payload["context"] = context
|
||||||
|
payload.update(metadata)
|
||||||
|
return self._post("/decisions", payload)
|
||||||
|
|
||||||
|
def query_decisions(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""Search recorded decisions."""
|
||||||
|
result = self._get("/decisions/search", {"q": query, "limit": limit})
|
||||||
|
return result.get("decisions", [])
|
||||||
|
|
||||||
|
def find_precedents(self, decision_text: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||||
|
"""Find past decisions similar to *decision_text*."""
|
||||||
|
result = self._post("/decisions/precedents", {"decision": decision_text, "top_k": top_k})
|
||||||
|
return result.get("precedents", [])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Analytics & reasoning
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_causal_chain(self, node_id: str, depth: int = 3) -> Dict[str, Any]:
|
||||||
|
"""Retrieve the causal chain rooted at *node_id*."""
|
||||||
|
return self._get("/causal-chain", {"node_id": node_id, "depth": depth})
|
||||||
|
|
||||||
|
def run_reasoning(self, rules: List[str], facts: List[str]) -> Dict[str, Any]:
|
||||||
|
"""Run the Semantica forward-chaining reasoner."""
|
||||||
|
return self._post("/reason", {"rules": rules, "facts": facts})
|
||||||
|
|
||||||
|
def get_graph_analytics(self) -> Dict[str, Any]:
|
||||||
|
"""Return graph-level analytics (centrality, communities, etc.)."""
|
||||||
|
return self._get("/analytics")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def export_graph(self, fmt: str = "json") -> str:
|
||||||
|
"""Export the graph in *fmt* (``json``, ``ttl``, ``graphml``, …)."""
|
||||||
|
result = self._get("/export", {"format": fmt})
|
||||||
|
return result.get("data", "")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_graph_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Return a high-level summary of the current graph."""
|
||||||
|
return self._get("/graph/summary")
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"OpenClawKGTool(base_url={self.base_url!r})"
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Semantica MCP Server
|
||||||
|
|
||||||
|
A fully modular [Model Context Protocol](https://modelcontextprotocol.io/) server for the Semantica knowledge graph.
|
||||||
|
Connects Claude Code, Cursor, Windsurf, Cline, Continue, VS Code (GitHub Copilot), and any other MCP-compatible AI tool directly to your Semantica graph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
pip install -e ".[mcp]"
|
||||||
|
|
||||||
|
# Test the server (type a JSON-RPC request, press Enter)
|
||||||
|
python -m mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Or point your AI tool at it (see per-tool configs below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
**stdio** — the server reads newline-delimited JSON-RPC 2.0 from `stdin` and writes responses to `stdout`.
|
||||||
|
Log/debug output goes to `stderr` only.
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m mcp [--debug]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools (17 total)
|
||||||
|
|
||||||
|
### Extraction
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `extract_entities` | Named entity recognition (NER) — people, places, orgs, concepts |
|
||||||
|
| `extract_relations` | Relation extraction + (subject, predicate, object) triplets |
|
||||||
|
| `extract_all` | Full pipeline: NER + coreference + relations + events + triplets |
|
||||||
|
|
||||||
|
### Decision Intelligence
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `record_decision` | Record a decision with context, confidence, causal links |
|
||||||
|
| `query_decisions` | Query decisions by natural language or structured filters |
|
||||||
|
| `find_precedents` | Find past decisions similar to a scenario (hybrid similarity) |
|
||||||
|
| `get_causal_chain` | Trace upstream/downstream causal chain from a decision |
|
||||||
|
| `analyze_decision_impact` | Analyse downstream influence of a decision |
|
||||||
|
|
||||||
|
### Knowledge Graph
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `add_entity` | Add a node/entity to the graph |
|
||||||
|
| `add_relationship` | Add a directed edge between two entities |
|
||||||
|
| `search_graph` | Search nodes by label or ID substring |
|
||||||
|
| `get_graph_summary` | Node/edge counts, decision count, type breakdown |
|
||||||
|
| `get_graph_analytics` | PageRank, betweenness, degree centrality, community detection |
|
||||||
|
|
||||||
|
### Reasoning
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `run_reasoning` | Forward-chaining IF/THEN rules over facts |
|
||||||
|
| `abductive_reasoning` | Generate plausible hypotheses for observations |
|
||||||
|
|
||||||
|
### Export & Provenance
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `export_graph` | Export graph to JSON, CSV, GraphML, Parquet, Turtle, N-Triples, RDF/XML, JSON-LD |
|
||||||
|
| `get_provenance` | Audit history and source lineage for a node |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources (4 total)
|
||||||
|
|
||||||
|
| URI | Description |
|
||||||
|
|---|---|
|
||||||
|
| `semantica://graph/summary` | Live node/edge counts and type breakdown |
|
||||||
|
| `semantica://decisions/list` | Most recent 50 decisions |
|
||||||
|
| `semantica://schema/info` | Schema version, node/edge types, tool names |
|
||||||
|
| `semantica://ontology/schema` | Full ontology schema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-tool configuration
|
||||||
|
|
||||||
|
### Claude Code (`~/.claude/settings.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the plugin bundle:
|
||||||
|
```bash
|
||||||
|
claude mcp add semantica python -m mcp --cwd /path/to/semantica
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cursor (`~/.cursor/mcp.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Windsurf (`~/.codeium/windsurf/mcp_config.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cline (VS Code extension settings)
|
||||||
|
|
||||||
|
In your VS Code `settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cline.mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Continue (`~/.continue/config.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": [
|
||||||
|
{
|
||||||
|
"name": "semantica",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### VS Code (GitHub Copilot) — `.vscode/mcp.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"semantica": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Amazon Q Developer
|
||||||
|
|
||||||
|
Add to your Q Developer MCP config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `SEMANTICA_KG_PATH` | *(in-memory)* | Path to persist/load the graph (JSON file) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp/
|
||||||
|
├── __init__.py # Package entry, re-exports SemanticaMCPServer + main
|
||||||
|
├── __main__.py # python -m mcp entry point
|
||||||
|
├── server.py # SemanticaMCPServer class + stdio event loop
|
||||||
|
├── session.py # Lazy ContextGraph singleton (get_graph / reset_graph)
|
||||||
|
├── schemas.py # JSON Schema definitions for all tool inputs
|
||||||
|
├── tools/
|
||||||
|
│ ├── __init__.py # Assembles TOOL_DEFINITIONS list
|
||||||
|
│ ├── extraction.py # NER, relation extraction, full pipeline
|
||||||
|
│ ├── decisions.py # Record, query, precedents, causal chain, impact
|
||||||
|
│ ├── graph.py # Add entity/relationship, search, summary, analytics
|
||||||
|
│ ├── reasoning.py # Forward-chaining rules, abductive hypotheses
|
||||||
|
│ └── export.py # Graph export (multi-format) + provenance
|
||||||
|
└── resources/
|
||||||
|
├── __init__.py # Re-exports RESOURCE_DEFINITIONS + handle_resource_read
|
||||||
|
└── registry.py # URI → handler map for the 4 semantica:// resources
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Semantica MCP Server Package
|
||||||
|
|
||||||
|
A full Model Context Protocol (MCP) server for Semantica — exposes knowledge graph
|
||||||
|
construction, semantic extraction, decision intelligence, reasoning, analytics,
|
||||||
|
and export capabilities as MCP tools and resources.
|
||||||
|
|
||||||
|
Run the server:
|
||||||
|
python -m mcp.server # from repo root
|
||||||
|
python -m semantica.mcp_server # alias inside installed package
|
||||||
|
|
||||||
|
Configure in Claude Desktop, Windsurf, Cline, Continue, VS Code:
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"semantica": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["-m", "mcp.server"],
|
||||||
|
"cwd": "/path/to/semantica"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .server import SemanticaMCPServer, main
|
||||||
|
|
||||||
|
__all__ = ["SemanticaMCPServer", "main"]
|
||||||
|
__version__ = "0.4.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point: python -m mcp.server"""
|
||||||
|
from mcp.server import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
MCP resource registry — static and dynamic resources exposed via resources/list
|
||||||
|
and resources/read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .registry import RESOURCE_DEFINITIONS, handle_resource_read
|
||||||
|
|
||||||
|
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Resource handlers for Semantica MCP resources.
|
||||||
|
|
||||||
|
Each resource maps a semantica:// URI to a callable that returns
|
||||||
|
{"uri": ..., "mimeType": ..., "text": ...}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.session import get_graph
|
||||||
|
|
||||||
|
log = logging.getLogger("semantica.mcp.resources")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_graph_summary(uri: str) -> dict:
|
||||||
|
try:
|
||||||
|
graph = get_graph()
|
||||||
|
all_nodes = list(graph.find_nodes())
|
||||||
|
node_types: dict[str, int] = {}
|
||||||
|
for n in all_nodes:
|
||||||
|
t = str(n.get("type", "Unknown"))
|
||||||
|
node_types[t] = node_types.get(t, 0) + 1
|
||||||
|
edge_count = 0
|
||||||
|
if hasattr(graph, "edge_count"):
|
||||||
|
try:
|
||||||
|
edge_count = graph.edge_count()
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Unable to read graph edge_count(); defaulting to 0: %s", exc)
|
||||||
|
data = {
|
||||||
|
"node_count": len(all_nodes),
|
||||||
|
"edge_count": edge_count,
|
||||||
|
"node_types": node_types,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
data = {"error": str(exc)}
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_decisions_list(uri: str) -> dict:
|
||||||
|
try:
|
||||||
|
graph = get_graph()
|
||||||
|
nodes = list(graph.find_nodes(node_type="decision"))
|
||||||
|
decisions = [
|
||||||
|
{
|
||||||
|
"id": n.get("id"),
|
||||||
|
"category": n.get("category"),
|
||||||
|
"outcome": n.get("outcome"),
|
||||||
|
"scenario": str(n.get("scenario", ""))[:120],
|
||||||
|
}
|
||||||
|
for n in nodes[:50]
|
||||||
|
]
|
||||||
|
data = {"decisions": decisions, "count": len(decisions)}
|
||||||
|
except Exception as exc:
|
||||||
|
data = {"error": str(exc), "decisions": []}
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, indent=2)}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_schema_info(uri: str) -> dict:
|
||||||
|
info = {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"node_types": [
|
||||||
|
"Entity", "decision", "Decision", "Event", "Concept",
|
||||||
|
"Person", "Organisation", "Location",
|
||||||
|
],
|
||||||
|
"edge_types": [
|
||||||
|
"RELATED_TO", "CAUSED_BY", "LEADS_TO", "PART_OF",
|
||||||
|
"INSTANCE_OF", "SIMILAR_TO",
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"extract_entities", "extract_relations", "extract_all",
|
||||||
|
"record_decision", "query_decisions", "find_precedents",
|
||||||
|
"get_causal_chain", "analyze_decision_impact",
|
||||||
|
"add_entity", "add_relationship", "search_graph",
|
||||||
|
"get_graph_summary", "get_graph_analytics",
|
||||||
|
"run_reasoning", "abductive_reasoning",
|
||||||
|
"export_graph", "get_provenance",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": json.dumps(info, indent=2)}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ontology_schema(uri: str) -> dict:
|
||||||
|
try:
|
||||||
|
graph = get_graph()
|
||||||
|
try:
|
||||||
|
from semantica.ontology import OntologyManager
|
||||||
|
mgr = OntologyManager(graph_store=graph)
|
||||||
|
schema = mgr.get_schema()
|
||||||
|
text = json.dumps(schema, indent=2) if isinstance(schema, dict) else str(schema)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
text = json.dumps({"message": "Ontology manager not available"}, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
text = json.dumps({"error": str(exc)}, indent=2)
|
||||||
|
return {"uri": uri, "mimeType": "application/json", "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
# Map URI → handler
|
||||||
|
_HANDLERS: dict[str, object] = {
|
||||||
|
"semantica://graph/summary": _read_graph_summary,
|
||||||
|
"semantica://decisions/list": _read_decisions_list,
|
||||||
|
"semantica://schema/info": _read_schema_info,
|
||||||
|
"semantica://ontology/schema": _read_ontology_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
"uri": "semantica://graph/summary",
|
||||||
|
"name": "Graph Summary",
|
||||||
|
"description": "High-level summary of the current knowledge graph: node/edge counts and type breakdown.",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "semantica://decisions/list",
|
||||||
|
"name": "Decision List",
|
||||||
|
"description": "Most recent decisions recorded in the knowledge graph (up to 50).",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "semantica://schema/info",
|
||||||
|
"name": "Schema Info",
|
||||||
|
"description": "Semantica schema version, supported node/edge types, and available tool names.",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "semantica://ontology/schema",
|
||||||
|
"name": "Ontology Schema",
|
||||||
|
"description": "Full ontology schema from the OntologyManager (concept hierarchy and constraints).",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def handle_resource_read(uri: str) -> dict:
|
||||||
|
"""Dispatch a resources/read request to the appropriate handler."""
|
||||||
|
handler = _HANDLERS.get(uri)
|
||||||
|
if handler is None:
|
||||||
|
return {
|
||||||
|
"uri": uri,
|
||||||
|
"mimeType": "application/json",
|
||||||
|
"text": json.dumps({"error": f"Unknown resource URI: {uri}"}),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return handler(uri) # type: ignore[call-arg]
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("resource_read failed for %s", uri)
|
||||||
|
return {
|
||||||
|
"uri": uri,
|
||||||
|
"mimeType": "application/json",
|
||||||
|
"text": json.dumps({"error": str(exc)}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Input schema definitions for all MCP tools.
|
||||||
|
|
||||||
|
Each entry is the JSON Schema object placed in the tool's ``inputSchema``
|
||||||
|
field. Keeping them here avoids duplication across tool modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXTRACTION_TEXT = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Input text to process",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTRACT_ENTITIES = EXTRACTION_TEXT
|
||||||
|
|
||||||
|
EXTRACT_RELATIONS = EXTRACTION_TEXT
|
||||||
|
|
||||||
|
EXTRACT_ALL = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string", "description": "Input text to process"},
|
||||||
|
"include_events": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Also extract events (default: true)",
|
||||||
|
},
|
||||||
|
"include_triplets": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Also extract (subject, predicate, object) triplets (default: true)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
RECORD_DECISION = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Decision category, e.g. 'loan_approval', 'deployment'",
|
||||||
|
},
|
||||||
|
"scenario": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Natural-language description of the situation",
|
||||||
|
},
|
||||||
|
"reasoning": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Explanation of why this decision was made",
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Decision result, e.g. 'approved', 'rejected', 'deferred'",
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Confidence score between 0 and 1",
|
||||||
|
},
|
||||||
|
"decision_maker": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Who or what made the decision (default: mcp_client)",
|
||||||
|
},
|
||||||
|
"valid_from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 validity start date (optional)",
|
||||||
|
},
|
||||||
|
"valid_until": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 8601 validity end date (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["category", "scenario", "reasoning", "outcome", "confidence"],
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY_DECISIONS = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Natural language query (optional)",
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by exact category (optional)",
|
||||||
|
},
|
||||||
|
"outcome": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by outcome value (optional)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 200,
|
||||||
|
"description": "Maximum number of results (default: 10)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
FIND_PRECEDENTS = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scenario": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scenario description to find similar past decisions for",
|
||||||
|
},
|
||||||
|
"max_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 50,
|
||||||
|
"description": "Maximum number of precedents to return (default: 5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["scenario"],
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_CAUSAL_CHAIN = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"decision_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID of the decision to trace",
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["upstream", "downstream", "both"],
|
||||||
|
"description": "Trace direction (default: downstream)",
|
||||||
|
},
|
||||||
|
"max_depth": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 20,
|
||||||
|
"description": "Maximum chain depth (default: 5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["decision_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ANALYZE_DECISION_IMPACT = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"decision_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ID of the decision to analyse",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["decision_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ADD_ENTITY = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique node identifier",
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable label (defaults to id)",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Node type, e.g. 'Person', 'Organisation', 'Concept'",
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Additional key-value properties",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ADD_RELATIONSHIP = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Source node ID",
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target node ID",
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relationship type, e.g. 'WORKS_AT', 'CAUSED_BY'",
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Additional edge properties",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["source", "target"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SEARCH_GRAPH = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Search term or phrase",
|
||||||
|
},
|
||||||
|
"node_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by node type (optional)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max results (default: 20)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
}
|
||||||
|
|
||||||
|
RUN_REASONING = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"facts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Fact strings, e.g. ['Person(John)', 'Employee(John)']",
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "IF/THEN rule strings, e.g. ['IF Employee(?x) THEN Worker(?x)']",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["facts", "rules"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ABDUCTIVE_REASONING = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"observations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Observed facts to explain",
|
||||||
|
},
|
||||||
|
"max_hypotheses": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max hypotheses to generate (default: 5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["observations"],
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPORT_GRAPH = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["turtle", "ttl", "nt", "xml", "json-ld", "json", "csv"],
|
||||||
|
"description": "Export format (default: json-ld)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_PROVENANCE = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entity_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Entity or node ID to get provenance for",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["entity_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
GET_ANALYTICS = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"metrics": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pagerank", "betweenness", "communities", "degree", "all"],
|
||||||
|
},
|
||||||
|
"description": "Analytics to compute (default: ['all'])",
|
||||||
|
},
|
||||||
|
"top_n": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Top N nodes to return per metric (default: 10)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPTY = {"type": "object", "properties": {}}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Semantica MCP Server — JSON-RPC 2.0 over stdio.
|
||||||
|
|
||||||
|
Implements the Model Context Protocol so any MCP-compatible AI tool
|
||||||
|
(Claude Code, Cursor, Windsurf, Cline, Continue, VS Code Copilot, etc.)
|
||||||
|
can interact with the Semantica knowledge graph.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python -m mcp # via __main__.py
|
||||||
|
python -m mcp.server # direct
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.resources import RESOURCE_DEFINITIONS, handle_resource_read
|
||||||
|
from mcp.tools import TOOL_DEFINITIONS
|
||||||
|
|
||||||
|
log = logging.getLogger("semantica.mcp.server")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ok(request_id: Any, result: Any) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
def _err(request_id: Any, code: int, message: str, data: Any = None) -> dict:
|
||||||
|
error: dict = {"code": code, "message": message}
|
||||||
|
if data is not None:
|
||||||
|
error["data"] = data
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
||||||
|
|
||||||
|
|
||||||
|
# JSON-RPC error codes
|
||||||
|
_PARSE_ERROR = -32700
|
||||||
|
_METHOD_NOT_FOUND = -32601
|
||||||
|
_INVALID_PARAMS = -32602
|
||||||
|
_INTERNAL_ERROR = -32603
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool dispatch index
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TOOL_INDEX: dict[str, dict] = {t["name"]: t for t in TOOL_DEFINITIONS}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_initialize(req_id: Any, params: dict) -> dict:
|
||||||
|
return _ok(req_id, {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {},
|
||||||
|
"resources": {},
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "semantica-mcp",
|
||||||
|
"version": "0.4.0",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_tools_list(req_id: Any, _params: dict) -> dict:
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": t["name"],
|
||||||
|
"description": t["description"],
|
||||||
|
"inputSchema": t["inputSchema"],
|
||||||
|
}
|
||||||
|
for t in TOOL_DEFINITIONS
|
||||||
|
]
|
||||||
|
return _ok(req_id, {"tools": tools})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_tools_call(req_id: Any, params: dict) -> dict:
|
||||||
|
name = params.get("name", "")
|
||||||
|
args = params.get("arguments", {}) or {}
|
||||||
|
|
||||||
|
tool = _TOOL_INDEX.get(name)
|
||||||
|
if tool is None:
|
||||||
|
return _err(req_id, _METHOD_NOT_FOUND, f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = tool["_handler"](args)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Tool %s raised an exception", name)
|
||||||
|
return _err(req_id, _INTERNAL_ERROR, str(exc))
|
||||||
|
|
||||||
|
# MCP spec: content must be a list of content items
|
||||||
|
return _ok(req_id, {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}],
|
||||||
|
"isError": "error" in result,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_resources_list(req_id: Any, _params: dict) -> dict:
|
||||||
|
return _ok(req_id, {"resources": RESOURCE_DEFINITIONS})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_resources_read(req_id: Any, params: dict) -> dict:
|
||||||
|
uri = params.get("uri", "").strip()
|
||||||
|
if not uri:
|
||||||
|
return _err(req_id, _INVALID_PARAMS, "uri is required")
|
||||||
|
resource = handle_resource_read(uri)
|
||||||
|
return _ok(req_id, {
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"uri": resource["uri"],
|
||||||
|
"mimeType": resource.get("mimeType", "application/json"),
|
||||||
|
"text": resource.get("text", ""),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_ping(req_id: Any, _params: dict) -> dict:
|
||||||
|
return _ok(req_id, {})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DISPATCH = {
|
||||||
|
"initialize": _handle_initialize,
|
||||||
|
"tools/list": _handle_tools_list,
|
||||||
|
"tools/call": _handle_tools_call,
|
||||||
|
"resources/list": _handle_resources_list,
|
||||||
|
"resources/read": _handle_resources_read,
|
||||||
|
"ping": _handle_ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main server class
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SemanticaMCPServer:
|
||||||
|
"""Semantica MCP server — reads JSON-RPC requests from stdin, writes to stdout."""
|
||||||
|
|
||||||
|
def __init__(self, *, debug: bool = False) -> None:
|
||||||
|
level = logging.DEBUG if debug else logging.WARNING
|
||||||
|
logging.basicConfig(stream=sys.stderr, level=level,
|
||||||
|
format="%(name)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, request: dict) -> dict | None:
|
||||||
|
"""Process one JSON-RPC request and return a response dict (or None for notifications)."""
|
||||||
|
req_id = request.get("id") # None for notifications
|
||||||
|
method = request.get("method", "")
|
||||||
|
params = request.get("params") or {}
|
||||||
|
|
||||||
|
handler = _DISPATCH.get(method)
|
||||||
|
if handler is None:
|
||||||
|
if req_id is None:
|
||||||
|
return None # Notification — ignore unknown methods silently
|
||||||
|
return _err(req_id, _METHOD_NOT_FOUND, f"Method not found: {method}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return handler(req_id, params)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Unhandled error in method %s", method)
|
||||||
|
if req_id is None:
|
||||||
|
return None
|
||||||
|
return _err(req_id, _INTERNAL_ERROR, str(exc))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Start the stdio event loop."""
|
||||||
|
log.info("Semantica MCP server starting (stdio)")
|
||||||
|
for raw_line in sys.stdin:
|
||||||
|
raw_line = raw_line.strip()
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
request = json.loads(raw_line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
response = _err(None, _PARSE_ERROR, f"Parse error: {exc}")
|
||||||
|
_write(response)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(request, list):
|
||||||
|
# Batch request
|
||||||
|
responses = []
|
||||||
|
for req in request:
|
||||||
|
resp = self.dispatch(req)
|
||||||
|
if resp is not None:
|
||||||
|
responses.append(resp)
|
||||||
|
if responses:
|
||||||
|
_write(responses)
|
||||||
|
else:
|
||||||
|
resp = self.dispatch(request)
|
||||||
|
if resp is not None:
|
||||||
|
_write(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(obj: Any) -> None:
|
||||||
|
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description="Semantica MCP Server")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||||
|
args = parser.parse_args()
|
||||||
|
SemanticaMCPServer(debug=args.debug).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
Shared graph session — lazy singleton across all tool handlers.
|
||||||
|
|
||||||
|
The graph is initialised once on first access and shared for the
|
||||||
|
lifetime of the MCP server process. Set SEMANTICA_KG_PATH to
|
||||||
|
automatically load a persisted graph on start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("semantica.mcp.session")
|
||||||
|
|
||||||
|
_graph: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph() -> Any:
|
||||||
|
"""
|
||||||
|
Return the shared ContextGraph instance, creating it on first call.
|
||||||
|
|
||||||
|
The graph is created with advanced_analytics=True so all centrality,
|
||||||
|
community-detection, and embedding features are available.
|
||||||
|
"""
|
||||||
|
global _graph
|
||||||
|
if _graph is None:
|
||||||
|
from semantica.context import ContextGraph
|
||||||
|
|
||||||
|
_graph = ContextGraph(advanced_analytics=True)
|
||||||
|
|
||||||
|
kg_path = os.environ.get("SEMANTICA_KG_PATH", "").strip()
|
||||||
|
if kg_path and os.path.exists(kg_path):
|
||||||
|
try:
|
||||||
|
_graph.load(kg_path)
|
||||||
|
log.info("Graph loaded from %s", kg_path)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not load graph from %s: %s", kg_path, exc)
|
||||||
|
|
||||||
|
return _graph
|
||||||
|
|
||||||
|
|
||||||
|
def reset_graph() -> None:
|
||||||
|
"""Reset the singleton (mainly useful in tests)."""
|
||||||
|
global _graph
|
||||||
|
_graph = None
|
||||||