Compare commits

...
88 Commits
Author SHA1 Message Date
Mohd KaifandCopilot Autofix powered by AI 6ffed78fd9 Potential fix for pull request finding 'Module is imported with 'import' and 'import from''
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-27 18:39:09 +05:30
KaifAhmad1 f06de0dab2 fix(context): address PR #512 review blockers and bot findings
Merge blockers (ZohaibHassan16):
- fix: distance-matrix raises HTTP 503 when metric=semantic but no
  similarity backend is available, instead of silently returning hop
  distances labeled as semantic
- fix: distance-enriched export now requires node_subset (HTTP 422 if
  omitted), preventing unbounded all-pairs O(n^2) export over full graph
- fix: DistanceExportRequest default include corrected from ["hops",
  "distance_band"] to ["source_id", "target_id", "hop_count",
  "distance_band"] so default exports are unambiguous and use the correct
  column name
- fix: confidence decay edge weight index now reads graph_dict.get("edges")
  or graph_dict.get("relationships") to handle both graph dict shapes,
  fixing always-1.0 decay when session returns relationships key

Bot findings (github-code-quality / chatgpt-codex):
- fix: remove unused Iterable import from distance_exporter.py
- fix: remove unused Response import from graph.py
- fix: move logger init before optional KG import; replace empty
  except ImportError: pass with logger.debug in distance_exporter.py
- fix: replace two bare except Exception: pass in temporal.distance_history
  with logger.warning including source, target, metric, and timestamp context
- fix: remove mixed import style in test_qual003 — use only module import
  and reference CausalChainAnalyzer through it
2026-04-27 18:28:04 +05:30
KaifAhmad1 dd016744ce feat(context): add distance intelligence across context, API, and Explorer (#502)
- ContextGraph.get_neighbors() gains include_distance_metadata flag (backward-compat)
- get_neighbor_distances() returns neighbors sorted by hop and confidence decay
- AgentContext.retrieve/find_precedents support proximity-weighted blending
- FR-4: path enrichment (decay, similarity, coherence, bottleneck, interpretation)
- FR-6: POST /api/graph/distance-matrix (hops/weighted/semantic, upper-triangle)
- FR-3: GET /api/graph/node/{id}/semantic-neighborhood
- FR-8: GET /api/decisions/causal-distance (causal-edge-only BFS)
- FR-9: GET /api/temporal/distance-history (convergence/divergence events)
- FR-10: POST /api/export/distance-enriched (CSV/JSONL, 200-node cap)
- Explorer: PathDistanceIntelPanel, Ego Mode, Structural/Semantic overlay, Heatmap
- Fix 13 Qodo review issues: API param mismatch, O(E*L) decay, breaking change,
  schema key inconsistency, datetime arithmetic, id overwrite, sweep race,
  node_subset DoS, full-matrix redundancy, effect race, silent exceptions, duplication
- 57 new tests in test_distance_intelligence.py; 18 regression tests in _smoke_review_fixes.py
2026-04-27 11:07:27 +05:30
Mohd KaifandClaude Sonnet 4.6 7884d71e23 feat(explorer): add welcome screen and fix root path Invalid path error (#501)
- Add WelcomeScreen shown on app load; SKE brand button navigates back
- Fix serve_spa: empty root path was hitting dot-guard returning 400
  Invalid path instead of index.html or a welcome JSON response

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:41:13 +05:30
Mohd Kaif ca5f081793 Merge pull request #493 from Hawksight-AI/feat/explorer-grouped-view
Feat/explorer grouped view
2026-04-25 15:44:04 +05:30
6ad1502224 fix(explorer): address grouped view review blockers
- Fix `import.meta.env.DEV` crash in graphSceneState.ts that broke the
  entire test:graph-workspace suite (module load fails in Node.js/tsx)
- Export `resolveGroupedDisplayNodeId` from graphSceneState.ts and
  remove the identical copy in GraphWorkspace.tsx
- Add `checkGroupedViewAvailability` helper (Louvain only, no centrality)
  so grouped view availability can be checked cheaply on every graph change
- Gate full community graph build (`groupedDisplayCandidate`) on
  `viewMode === 'grouped'` to avoid running Louvain + centrality on every
  graph version tick when the user is not in grouped view
- Remove dead ternary in `focusNode` where both branches returned `nodeId`
- Add 7 new tests covering resolveGroupedDisplayNodeId,
  resolveGroupedDisplayStateSnapshot, and checkGroupedViewAvailability

Co-Authored-By: ZohaibHassan16 <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-25 13:42:15 +05:30
Zohaib Hassnain b010ba68fa Merge origin/main into feat/explorer-grouped-view 2026-04-25 02:54:48 +05:00
Zohaib Hassnain 7c8dfbd3c0 feat(explorer): stabilize and refine grouped graph view 2026-04-25 02:40:51 +05:00
dependabot[bot] 45400c88d3 ci(deps): bump actions/upload-pages-artifact from 3 to 5 (#485)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:10:05 +05:30
dependabot[bot] 26f6cdf9e5 docker(deps): bump python from 3.12-slim to 3.14-slim (#466)
Bumps python from 3.12-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:07:11 +05:30
dependabot[bot] 96f88594c2 docker(deps): bump node from 20-alpine to 25-alpine (#465)
Bumps node from 20-alpine to 25-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 25-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 13:03:24 +05:30
Mohd Kaif c0d08c46f7 Merge pull request #486 from Hawksight-AI/fix/graph-motion
Fix explorer zooming and Loading Flicker
2026-04-23 19:13:37 +05:30
Mohd KaifandCopilot Autofix powered by AI 5a388d0bcc Potential fix for pull request finding 'Useless conditional'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-23 19:02:31 +05:30
KaifAhmad1andZohaibHassan16 f516aef8fd fix(explorer): address PR #486 review blockers + resolve conflict with main
Merge conflict resolution:
- Kept fix/graph-motion's conditional layout-stop (only in focused mode)
  to preserve live layout motion for derived graphs — the core intent of
  this PR.

Must-fix items resolved:

1. plugin.json — removed "hooks": "./hooks/hooks.json" (re-added by this
   branch, already removed in PR #489 on main as it is auto-loaded).
   Kept "agents": "./agents".

2. Double Louvain per render — added groupedViewAvailable useMemo in
   GraphWorkspace (deps: [graphVersion]) that runs community detection
   once. Passed result into resolveDisplayGraph and resolveDisplayStateSnapshot
   via new groupedViewAvailable option; both functions skip their internal
   computeGraphAnalyticsBase call when the value is pre-supplied.

3. graphVersion in displayState deps — removed graphVersion from the
   displayState memo dep array. displayState now depends on the stable
   boolean groupedViewAvailable, not on every ADD_NODE/ADD_EDGE tick,
   so Louvain no longer re-fires on every WebSocket update.

4. hideLabelsOnMove / hideEdgesOnMove flipped to true — intentional:
   suppressing labels and edges during pan reduces visual noise and is
   part of the flicker-reduction fix described in the PR.

Co-authored-by: ZohaibHassan16 <zohaibhassan1696@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-23 18:58:48 +05:30
Mohd Kaif c9a382e676 Merge pull request #487 from Sameer6305/feat/explorer-stabilize-local-graph-interaction
feat(explorer): stabilize local graph interaction
2026-04-23 18:28:45 +05:30
KaifAhmad1andSameer6305 897d950bdc fix(explorer): address PR #487 review blockers
1. Prevent active-but-disabled Focused button by only disabling when
   viewMode is not already "focused" (viewMode !== "focused" && !canActivateFocusedMode).
2. Generalize inspector fallback copy — stale/invalid node IDs are not
   necessarily grouped items, so remove the misleading "Activate Focused
   mode" hint.
3. Move pluginRuntimeRef.current read out of render by converting
   canActivateFocusedMode from useMemo to useState + useEffect, resolving
   two ESLint "cannot access refs during render" errors and the missing
   toolbar-memo dependency warning.

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-23 18:24:31 +05:30
Mohd Kaif dd8fa17db8 Merge pull request #489 from musicload/fix/local-plugin-install
fix(plugin): make local Claude Code plugin install work out of the box
2026-04-23 11:42:20 +05:30
KaifAhmad1 92801c220e docs(plugin): align Claude install commands with marketplace flow 2026-04-23 11:34:38 +05:30
Serge 738480606c fix(plugin): drop hooks field from plugin.json (auto-loaded)
Claude Code auto-loads hooks/hooks.json. Declaring it explicitly in
manifest.hooks causes: 'Duplicate hooks file detected ... already-loaded'.
Same pattern as agents: manifest should only reference *additional* hook
files beyond the default.
2026-04-22 16:03:00 -04:00
Serge f9e0bcf210 fix(plugin): make local plugin install work out of the box
Two separate schema issues blocked `/plugin marketplace add ./plugins`
followed by `/plugin install semantica@semantica-local`:

1. `marketplace.json` was missing the required top-level `owner` object.
   Claude Code rejects with: `owner: Invalid input: expected object,
   received undefined`.

2. `plugin.json` declared `"agents": "./agents"` (string), but Claude
   Code's manifest schema rejects non-array `agents` with:
   `Validation errors: agents: Invalid input`. Auto-discovery from
   the default `agents/` directory works when the field is omitted,
   provided agents are flat `<name>.md` files with frontmatter (Claude
   Code's subagent convention) rather than `<name>/AGENT.md`
   subdirectories.

Changes:
- add `owner` object to `marketplace.json`
- drop `agents` field from `plugin.json` (falls back to auto-discovery)
- rename `agents/<name>/AGENT.md` -> `agents/<name>.md` (frontmatter
  content is unchanged, just the path)

After this, the documented local-install flow succeeds end-to-end.
2026-04-22 15:50:34 -04:00
Sameer6305 bb0e9f49e3 feat(explorer): stabilize local graph interaction 2026-04-23 00:11:38 +05:30
Zohaib Hassnain f95c1612d5 fix blinking and zooming problem 2026-04-22 03:33:09 +05:00
Zohaib Hassnain 8c202a691e fix(explorer): restore live layout motion for derived graphs 2026-04-22 03:31:36 +05:00
Mohd Kaif 304b82fbd6 Merge pull request #483 from ZohaibHassan16/feat/graph-declutter-and-calm
feat(explorer): calm and structurally declutter graph workspace
2026-04-20 17:50:00 +05:30
KaifAhmad1andZohaib Hassnain 8d2dfaa53c docs(changelog): add PR #483 explorer declutter release notes
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:33:12 +05:30
KaifAhmad1andZohaib Hassnain 39aaae778f Merge origin/main into feat/graph-declutter-and-calm
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:20:47 +05:30
KaifAhmad1andZohaib Hassnain 16d628997a test(explorer): cover graph display declutter flows
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-20 17:14:40 +05:30
Mohd Kaif 5e6ad6e87e Merge pull request #482 from liling/main
fix(providers): switch DeepSeekProvider from deepseek SDK to OpenAI c…
2026-04-19 20:16:25 +05:30
Mohd Kaif f6198039fa Merge branch 'main' into main 2026-04-19 20:10:22 +05:30
983f5301e8 fix(providers): switch DeepSeekProvider to OpenAI SDK + fix base_url and verbose_mode (closes #482)
- Replace deepseek.Client with openai.OpenAI(base_url="https://api.deepseek.com/v1")
  in DeepSeekProvider._init_client(); the deepseek PyPI package has no Client class
- Add self.base_url = "https://api.deepseek.com/v1" to DeepSeekProvider.__init__()
  (missing from original PR; caused AttributeError on every instantiation)
- Fix verbose_mode NameError in BaseProvider.generate_typed() instructor path
- Update pyproject.toml: llm-deepseek extra now declares openai>=1.0.0
- Update _init_client warning message to reference openai library
- Add 19 tests in tests/semantic_extract/test_pr482_deepseek_openai.py
- Update CHANGELOG.md

Co-authored-by: liling <liling@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 20:07:44 +05:30
Mohd Kaif 5a852169be Merge pull request #481 from ZohaibHassan16/feat/optimize-search
Feat/optimize search
2026-04-19 19:04:13 +05:30
KaifAhmad1 fe6ca7fccb fix(search-index): restore secondary-scan node ordering and add regression test 2026-04-19 18:46:05 +05:30
Mohd Kaif 66c8431eee Merge branch 'main' into feat/optimize-search 2026-04-19 18:25:28 +05:30
3e2a0a3f3b docs(changelog): add indexed search performance entry (#481, #467)
Co-authored-by: ZohaibHassan16 <zohaibhassan16@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 18:19:00 +05:30
d22a54353a fix(search-index): bisect ops, thread-safe mutation bridge, drop edge upserts
- Replace list.sort() on every upsert with bisect.insort() — O(log n) per
  insert instead of O(n log n); bulk rebuild still sorts once at the end
- Replace list.remove() in remove() with bisect.bisect_left + pop() — O(log n)
  find instead of O(n) scan
- Wrap handle_graph_mutation() index mutations in self._lock — mutation bridge
  fires from a background thread and was racing concurrent search/rebuild calls
- Drop source/target upserts in add_edge() — edges don't change node text so
  the index documents are identical; removes unnecessary cache invalidation
- Sort tag values in _cache_key() — ["a","b"] and ["b","a"] now share a cache
  entry since _passes_filters() uses set intersection (order-independent)
- Restore @app.get("/") root handler missing from this branch vs main

Co-authored-by: ZohaibHassan16 <zohaibhassan16@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 18:07:42 +05:30
Mohd Kaif f165679c11 Merge pull request #480 from Sameer6305/fix/provenance-ego-graph
fix(provenance): include upstream ancestors + add direction classific…
2026-04-19 17:50:03 +05:30
17460edca9 docs(changelog): add provenance upstream traversal fix entry (#480, #470)
Co-authored-by: Sameer6305 <sameer6305@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 17:36:58 +05:30
7e815920ac fix(provenance): resolve merge conflicts, fix session API, move schemas
- Resolve all merge conflict markers in provenance.py, app.py, .gitignore
- Revert broken session.get_nodes()/get_edges() to session.graph.nodes/edges
- Keep undirected=True ego_graph fix for upstream ancestor traversal
- Add direction field to ProvenanceEdge (upstream/downstream/lateral)
- Group lineage edges in _render_markdown by direction section
- Move ProvenanceNode/ProvenanceEdge/ProvenanceResponse to schemas.py
- Restore complete router import set in app.py (sparql, vocabulary, etc.)

Co-authored-by: Sameer6305 <sameer6305@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-19 17:30:03 +05:30
Zohaib Hassnain 7f93eb7104 feat(explorer): calm and structurally declutter graph workspace 2026-04-19 01:13:37 +05:00
Ling Li eec3e8804a fix(providers): add missing verbose_mode assignment in generate_typed 2026-04-19 00:12:25 +08:00
Ling Li 9cb6073568 fix(providers): switch DeepSeekProvider from deepseek SDK to OpenAI client
DeepSeek API is compatible with OpenAI, use the openai SDK instead of
the unmaintained deepseek SDK for better compatibility.
2026-04-18 23:21:49 +08:00
Zohaib Hassnain 073c48882c chore 2 2026-04-17 21:24:45 +05:00
Zohaib Hassnain be86d1b5db chore: remove local benchmark helper 2026-04-17 21:23:51 +05:00
Zohaib Hassnain 6f93f429c4 perf(explorer): add indexed search for large graphs 2026-04-17 21:22:12 +05:00
Mohd KaifandCopilot bc683e7a34 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:48:57 +05:30
Mohd KaifandCopilot cda5310949 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:48:45 +05:30
Mohd KaifandCopilot 17f88ca600 Update semantica/explorer/routes/provenance.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 20:47:10 +05:30
Sameer6305 658de23357 fix: resolve merge conflicts with upstream main 2026-04-17 19:56:41 +05:30
Sameer6305 66e8964d22 fix(provenance): include upstream ancestors + add direction classification and markdown grouping 2026-04-17 19:33:12 +05:30
Mohd KaifandClaude Sonnet 4.6 892ff4b4a7 fix(export): fix OWLExporter Turtle invalid syntax and silent data-property omission (#478) (#479)
- Add _ttl_block() helper to accumulate all predicate-object pairs before
  writing, producing a single valid Turtle subject block terminated by one
  period — eliminates the bug where rdfs:subClassOf / domain / range were
  appended after a closed '.' block
- Add missing data_properties loop to _export_owl_turtle so
  owl:DatatypeProperty declarations are no longer silently dropped
- Add _escape_ttl_str() to escape quotes, backslashes, newlines, carriage
  returns, and tabs inside Turtle string literals (rdfs:label, rdfs:comment,
  owl:versionInfo)
- Unify optional-field null checks to consistent x = prop.get(); if x: pattern
- Add 43 tests in tests/export/test_owl_exporter.py covering syntax validity,
  data properties, string escaping, null handling, and header output
- Update CHANGELOG.md with [Unreleased] entry

Closes #478

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:08:59 +05:30
Mohd Kaif a88300d74f Merge pull request #477 from Hawksight-AI/feat/node-distance-semantics-472
feat(explorer): add node distance semantics to PathResponse (#472)
2026-04-16 19:46:42 +05:30
KaifAhmad1 390152c78c feat(explorer): add node distance semantics to PathResponse (#472)
- Extend PathResponse with hop_count (len(path)-1) and distance_band
  ("direct"|"near"|"mid-range"|"distant") as first-class API fields
- Add classify_path_distance() to semantica/utils/helpers.py as the
  single source of truth for hop-count thresholds; both the route and
  the visualizer import from it, eliminating duplicate threshold logic
- Populate hop_count and distance_band in find_path route via
  classify_path_distance(); remove local _classify_distance() copy
- Add highlight_path: list[str] param to KGVisualizer.visualize_network;
  path edges rendered as a distance-aware orange trace (opacity and
  stroke width scale from direct→distant: 1.0/4px to 0.35/1.5px)
- Fix bidirectional edge lookup: only forward pairs (A→B) along the
  path are added to path_edge_set; reverse back-edges in directed
  graphs are no longer incorrectly highlighted
- Add logger.warning when highlight_path contains node IDs absent from
  the layout position map, surfacing silent no-op mismatches
- Extend frontend PathResponse type in GraphInspectorPanel.tsx and
  GraphWorkspaceShell.tsx with hop_count: number and distance_band
  literal union to match the updated API contract
- Add 10 new tests: 2 API-level and 8 unit tests covering all four
  band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops); 104 explorer tests
  pass, 0 failures introduced
- Update CHANGELOG.md
2026-04-16 17:59:50 +05:30
Mohd Kaif 17602812f9 Merge pull request #476 from Hawksight-AI/feat/bidirectional-path-finding-469
feat(explorer): Bidirectional Path Finding in Knowledge Explorer
2026-04-16 15:29:47 +05:30
KaifAhmad1andClaude Sonnet 4.6 523b02083f feat(explorer): add bidirectional path finding with directed=false param (#469)
- PathFinder.bfs_shortest_path() and dijkstra_shortest_path() gain a
  directed: bool = True parameter. When False, a temporary undirected
  view (graph.to_undirected()) is used for traversal only; the original
  directed edges are preserved and returned in the response.
- _make_undirected_view() helper added to PathFinder; falls back safely
  for non-NetworkX graph types.
- GET /api/graph/node/{id}/path exposes ?directed=false query param.
- PathResponse gains a directed: bool field echoing the mode used.
- Route now returns 404 on empty path (previously returned 200 with
  path: []).
- 21 new tests: 12 unit (TestBidirectionalPathFinding) + 9 API-level
  (TestBidirectionalPathRoute). All 120 tests pass.
- CHANGELOG updated under [Unreleased].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 15:20:12 +05:30
Mohd Kaif 952a4530f5 Merge pull request #474 from Hawksight-AI/kg
feat(kg): Native `KnowledgeGraph` Support in `KGVisualizer`
2026-04-16 12:26:19 +05:30
KaifAhmad1 2ce5067aa3 docs(changelog): add entry for #471 native KnowledgeGraph support in KGVisualizer 2026-04-16 12:18:38 +05:30
KaifAhmad1 d056e47ab7 feat(kg): add KnowledgeGraph dataclass and native KGVisualizer support (#471)
- Add semantica/kg/knowledge_graph.py with KnowledgeGraph dataclass
  (entities, relationships, metadata) plus __len__ and __bool__ helpers
- Export KnowledgeGraph from semantica/kg/__init__.py
- Add KGVisualizer._convert_knowledge_graph() for explicit, non-mutating
  conversion from KnowledgeGraph to internal dict format
- Route isinstance(graph, KnowledgeGraph) through _convert_knowledge_graph
  inside _normalize_graph so all five visualize_* entry points accept
  KnowledgeGraph directly without any manual conversion
- Add TestFormalKnowledgeGraphType (15 tests)

Closes #471
2026-04-16 12:15:59 +05:30
Mohd KaifandClaude Sonnet 4.6 8eafd2d024 fix(explorer): replace KeyError/ValueError with HTTPException across all routes, fix temporal pattern method, add SPA root handler (#463)
- routes/graph.py: raise HTTPException(404) for missing nodes; wrap path_finder call in try/except → HTTPException(404)
- routes/decisions.py: raise HTTPException(404) on chain, compliance, precedents sub-routes
- routes/annotations.py: raise HTTPException(404) on create (missing node) and delete (missing annotation)
- routes/enrich.py: raise HTTPException(404/503/422) for missing nodes, unavailable services, and extraction errors
- routes/temporal.py: fix TemporalPatternDetector method name detect_patterns → detect_temporal_patterns
- explorer/app.py: root / now serves built index.html when available, falls back to minimal HTML shell; add HTMLResponse import
- tests/explorer/test_explorer_api.py: test_extract accepts 503 (spacy/transformers not installed is a valid service state)

All 45 explorer API integration tests pass.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:19:19 +05:30
Mohd Kaif 7ba93f6772 Add initialization file for Claude 2026-04-14 23:25:20 +05:30
Mohd Kaif d466203761 Add initialization file for Claude skills 2026-04-14 23:24:39 +05:30
Mohd Kaif 730dea7911 Add initialization comment to semantica file 2026-04-14 23:24:00 +05:30
Mohd KaifandClaude Sonnet 4.6 47764c3033 Utils Explorer Welcome Message, Version Bump & Plugin README Overhaul (#462)
* Clarify plugin README install and usage steps

* feat(explorer): add welcome message to root endpoint and bump version to 0.4.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plugins): update all plugin READMEs for v0.4.0 with full platform list

- Rewrite main community guide with platform table (8 plugins), skills/agents
  inventory, Knowledge Explorer section, and per-platform install steps
- Add v0.4.0 badge and Knowledge Explorer section to VS Code, Cline,
  Continue, Windsurf, and OpenClaw READMEs
- Fix inconsistent tool count (12 → 17) across all READMEs
- Bump Python requirement from 3.8+ to 3.10+ across all plugins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: add PR description for utils → main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove PR_DESCRIPTION.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:32:04 +05:30
Mohd KaifandClaude Sonnet 4.6 055d2fd98d docs: reorganise README integrations and agentic frameworks sections (#461)
- Remove duplicate integrations table from bottom of README
- Move Agentic Frameworks section to top alongside AI tools table
- Show only Agno as supported; list LangChain, LangGraph, CrewAI, LlamaIndex, AutoGen, OpenAI Agents SDK, Google ADK as coming soon
- Add logo icons for all agentic frameworks matching existing plugin style

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:08:18 +05:30
Mohd Kaif ce66681715 Delete RELEASE_NOTES.md 2026-04-14 14:11:59 +05:30
Mohd Kaif 655b553262 Delete STRATEGIES_SUMMARY.md 2026-04-14 14:11:37 +05:30
Mohd KaifandClaude Sonnet 4.6 60bf8ec75e feat(integrations): add OpenClaw plugin and integration module (#460)
- Add integrations/openclaw/ with OpenClawKGTool (REST) and
  OpenClawMCPConfig (mcporter.json generator)
- Add plugins/.openclaw-plugin/ bundle (plugin.json, marketplace.json,
  README) with MCP + native tool support
- Add OpenClaw badge to README header
- Reorganize "Works With Every AI Tool" table into labeled groups:
  Native Plugin Bundle, MCP Server + Plugin, MCP Server, REST API

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:27:25 +05:30
Mohd Kaif a1478af9c4 Merge pull request #453 from Hawksight-AI/explorer
feat(explorer): add Semantica Knowledge Explorer UI with full feature…
2026-04-14 11:43:57 +05:30
Mohd Kaif ee4d6a9188 Update CHANGELOG with recent changes and fixes
Updated CHANGELOG to reflect recent fixes and security enhancements, including improvements to KGVisualizer and vulnerability fixes.
2026-04-14 11:21:27 +05:30
Mohd Kaif 2d00257ae5 Merge pull request #459 from Hawksight-AI/visualization
fix(visualization): Accept KnowledgeGraph objects in all `visualize_*` methods
2026-04-14 11:18:41 +05:30
Mohd KaifandCopilot Autofix powered by AI 1cc2f6b93a Potential fix for pull request finding 'Wrong number of arguments in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:52:23 +05:30
Mohd KaifandCopilot Autofix powered by AI 9e26d96b3c Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:50 +05:30
Mohd KaifandCopilot Autofix powered by AI 7267425eb5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:34 +05:30
Mohd KaifandCopilot Autofix powered by AI d9cf7b0088 Potential fix for pull request finding 'Unused global variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:46:14 +05:30
Mohd KaifandCopilot Autofix powered by AI 09666806da Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:55 +05:30
Mohd KaifandCopilot Autofix powered by AI 9daddd8186 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:45:40 +05:30
Mohd KaifandCopilot Autofix powered by AI dc8d7ddb03 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:44 +05:30
Mohd KaifandCopilot Autofix powered by AI b34634c8b5 Potential fix for pull request finding 'Wrong number of arguments in a call'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:28 +05:30
Mohd KaifandCopilot Autofix powered by AI ee93c4bbe1 Potential fix for pull request finding 'Wrong name for an argument in a class instantiation'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:44:13 +05:30
Mohd KaifandCopilot Autofix powered by AI e4425818e4 Potential fix for pull request finding 'Empty except'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-13 17:43:56 +05:30
KaifAhmad1andClaude Sonnet 4.6 7b31304e1e feat(mcp): add modular MCP server package at repo root
Adds a fully self-contained `mcp/` package that exposes Semantica as a
Model Context Protocol server over stdio (JSON-RPC 2.0).

17 tools across 5 domains:
- Extraction: extract_entities, extract_relations, extract_all
- Decision intelligence: record_decision, query_decisions, find_precedents,
  get_causal_chain, analyze_decision_impact
- Knowledge graph: add_entity, add_relationship, search_graph,
  get_graph_summary, get_graph_analytics
- Reasoning: run_reasoning, abductive_reasoning
- Export & provenance: export_graph (JSON/CSV/GraphML/Parquet/RDF), get_provenance

4 resources: semantica://graph/summary, semantica://decisions/list,
semantica://schema/info, semantica://ontology/schema

Package layout:
  mcp/__init__.py + __main__.py  — entry points (python -m mcp)
  mcp/server.py                  — SemanticaMCPServer + stdio event loop
  mcp/session.py                 — lazy ContextGraph singleton
  mcp/schemas.py                 — JSON Schema for all 17 tool inputs
  mcp/tools/{extraction,decisions,graph,reasoning,export}.py
  mcp/resources/registry.py      — URI → handler map
  mcp/README.md                  — per-tool setup (Claude Code, Cursor, Windsurf,
                                   Cline, Continue, VS Code, Amazon Q)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 17:38:23 +05:30
KaifAhmad1andClaude Sonnet 4.6 ab93ec3e8f feat(plugins): add MCP server + 4 new plugin bundles (Windsurf, Cline, Continue, VS Code)
MCP Server (semantica/mcp_server.py):
- Full stdio-based MCP server compatible with Claude Desktop, Windsurf,
  Cline, Continue, VS Code, Roo Code, and any MCP-aware tool
- 12 tools: 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
- Lazy graph session with optional SEMANTICA_KG_PATH env var
- JSON-RPC 2.0 over stdin/stdout; run with: python -m semantica.mcp_server

New plugin bundles (each: plugin.json + marketplace.json + README.md):
- plugins/.windsurf-plugin/ — Windsurf MCP config + 17 skills + 3 agents
- plugins/.cline-plugin/    — Cline MCP config + 17 skills + 3 agents
- plugins/.continue-plugin/ — Continue MCP config + 17 skills + 3 agents
- plugins/.vscode-plugin/   — VS Code MCP config + 17 skills + 3 agents

Updated plugins/.claude-plugin/README.md:
- Platform support table expanded to 9 tools
- Full MCP server section: per-tool config snippets for Claude Desktop,
  Windsurf, Cline, Continue, VS Code; tool/resource reference tables;
  environment variables

Updated README.md:
- Hero line updated to mention MCP server
- Visual grid: Windsurf/VS Code/Cline/Continue → 'MCP server + plugin';
  Claude Desktop → 'MCP server'
- Plugin Bundles section: expanded table listing all 7 bundles with dirs
- New MCP Server section with quick-start snippet and tool/resource list
- Detailed integrations table: corrected connection types and config paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:59:45 +05:30
KaifAhmad1andClaude Sonnet 4.6 f2eb3e1608 docs(readme): accurate plugin/integration/API docs based on actual code
Tools grid:
- Claude Code/Cursor/Codex: 'Native plugin' (plugins/ dirs exist in repo)
- All other tools: 'REST API' (no MCP server impl in codebase — Semantica
  has an MCP CLIENT for ingesting from MCP servers, not an MCP server)
- Codex CLI added back (has real plugin bundle at plugins/.codex-plugin/)

Plugin Bundles section:
- Full table of all 17 skills with descriptions matching SKILL.md files
- Full table of all 3 agents (kg-assistant, decision-advisor, explainability)
- Hooks entry referencing plugins/hooks/hooks.json

MCP Client section:
- Correct framing: MCPClient in semantica/ingest/mcp_client.py pulls
  data FROM MCP servers into KG (not an MCP server itself)
- Code snippet + supported schemes

REST API Server section:
- Lists all 10 route modules from semantica/explorer/routes/ with paths
- WebSocket /ws endpoint
- Health check

Agno integration section:
- Expanded to table showing all 5 actual files in integrations/agno/
  with class names and descriptions matching source code

AI Coding Tools table:
- Corrected connection types and setup notes to match actual code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:51:34 +05:30
KaifAhmad1andClaude Sonnet 4.6 898a660ca7 docs(readme): add explicit integrations table for all 16 AI tools + expand sections
- Add 'AI Coding Tools & IDEs' table under Integrations listing every
  tool from the visual grid with connection type and setup note:
  Claude Code, Cursor, Windsurf, Claude Desktop, VS Code, GitHub
  Copilot, Cline, Roo Code, Continue, Goose, Kilo Code, Aider,
  Amazon Q, Zed, Claude SDK, REST API (109 endpoints)
- Add Neo4j to Graph Databases list (was in modules but missing here)
- Add Email and Repository ingestors to Data Sources
- Expand LLM Providers: add Groq, HuggingFace, Ollama entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:25:02 +05:30
KaifAhmad1andClaude Sonnet 4.6 fb1e6a6d6e docs(readme): revise tools grid with accurate popular integrations
AI tools grid (removed Gemini CLI, Codex CLI; added VS Code, GitHub
Copilot, Continue, Amazon Q, Zed — all confirmed MCP-supporting tools
with significant user bases in 2026):
Row 1: Claude Code, Cursor, Windsurf, Claude Desktop, VS Code,
        GitHub Copilot, Cline, Roo Code
Row 2: Continue, Goose, Kilo Code, Aider, Amazon Q, Zed,
        Claude SDK, Any agent REST API

Agentic frameworks grid (added LangGraph and OpenAI Agents SDK, expanded
to 8 entries): Agno, LangChain, LangGraph, LlamaIndex, AutoGen, CrewAI,
OpenAI Agents SDK, Google ADK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:11:00 +05:30
KaifAhmad1andClaude Sonnet 4.6 0d7b9ca1df docs(readme): add Semantica Knowledge Explorer section to main README
- New '🖥️ Semantica Knowledge Explorer' section placed after Plugins,
  with a workspace-tab table (Graph, Timeline, Decisions, Registry,
  Entity Resolution, KG Overview, Ontology), a 4-line quick-start
  snippet, requirements line, and a pointer to explorer/README.md
- Added explorer/ row to the detailed Modules table with a link
- Added explorer/ bullet to the condensed Modules list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:59:21 +05:30
KaifAhmad1andClaude Sonnet 4.6 aca15d3694 docs(explorer): replace default Vite README with full local setup guide
Covers requirements (Node 18+/Python 3.8+), backend start command,
npm install, dev server, all 6 workspace tabs, available npm scripts,
API/WebSocket proxy table, production build, troubleshooting steps,
and tech stack summary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:30:27 +05:30
KaifAhmad1andClaude Sonnet 4.6 670027fd22 fix(explorer): resolve 3 code-review bugs in GraphWorkspace, DecisionWorkspace, and index.css
- GraphWorkspace: set isRunningPredictions=true before link-prediction fetch
  and false in finally block; pass isRunningPredictions prop to
  LazyGraphInspectorPanel so the inspector button disables and shows a
  spinner during the request (was declared but never wired — broke
  noUnusedLocals TypeScript build)

- DecisionWorkspace: add AbortController to the /api/decisions useEffect
  so the fetch is cancelled on unmount; add per-call AbortController to
  handleSelectDecision for /api/decisions/:id/chain; add res.ok guards
  before .json() on both fetches; encodeURIComponent on decision_id to
  prevent path-injection edge cases

- index.css: add missing @keyframes skeleton-pulse rule (0%/100% opacity
  0.45, 50% opacity 0.85) — KGOverviewTab skeletonBarStyle referenced
  this animation but it was never defined, leaving skeleton bars static

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:16:59 +05:30
KaifAhmad1andClaude Sonnet 4.6 3ea1283626 feat(explorer): add Semantica Knowledge Explorer UI with full feature set
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility

## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
  Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
  Decision Intelligence · Knowledge Audit · Graph Governance

## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store

### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
  INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)

## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button

### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab

## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches

## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
  clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections

## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response

## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone:    rgba(…, 0.04)  → rgba(…, 0.38)
- edgeStructure:   rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection:  rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges:     0.009–0.02    → 0.12–0.26
- Focus edges:     0.16          → 0.42

### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize:    1.8  → 2.4
- inactive/muted:  hide:true → hide:false (dimmed not hidden)

### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize:        0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale:     0.66 → 0.88
- nodeTintMix (colour):   0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72

### Label budget
- overview:   10  → 28 labels
- structure:  36  → 60 labels
- inspection: 80  → 120 labels

### Sigma settings
- renderEdgeLabels:        false → true  (relationship type on every edge)
- edgeLabelSize:           —    → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity:            0.86 → 1.1
- hideLabelsOnMove:        true → false (labels stay visible while panning)
- hideEdgesOnMove:         true → false (edges stay visible while panning)
- minCameraRatio:          —    → 0.04 (prevents zooming inside a node)
- maxCameraRatio:          —    → 8    (graph stays visible when zoomed out)

### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:40:40 +05:30
170 changed files with 17945 additions and 4307 deletions
+1
View File
@@ -0,0 +1 @@
# Initialization
+1
View File
@@ -0,0 +1 @@
# Intialization
+1
View File
@@ -0,0 +1 @@
# Initialization
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
continue-on-error: true
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: ./site
+7
View File
@@ -111,5 +111,12 @@ sample_data/
# Test Results
test_results.txt
# Frontend workspace artifacts
semantica-explorer/
node_modules/
# Frontend build artifacts (generated by Vite — do not track in git)
semantica/static/
# Local graph explorer test datasets
demo_out/
+78
View File
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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 18), 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 24471 ms; a session with 500 k nodes was effectively unusable.
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (212 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (01 hops), `"near"` (23), `"mid-range"` (46), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
**Critical**
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-builder
FROM node:25-alpine AS frontend-builder
WORKDIR /app/semantica-explorer
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
RUN npm run build
FROM python:3.12-slim AS runtime
FROM python:3.14-slim AS runtime
WORKDIR /app
+316 -27
View File
@@ -14,6 +14,7 @@
[![CI](https://github.com/Hawksight-AI/semantica/workflows/CI/badge.svg)](https://github.com/Hawksight-AI/semantica/actions)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white)](https://discord.gg/sV34vps5hH)
[![X](https://img.shields.io/badge/X-Follow%20Semantica-black?logo=x&logoColor=white)](https://x.com/BuildSemantica)
[![OpenClaw](https://img.shields.io/badge/OpenClaw-Plugin-FF3B30?logo=github&logoColor=white)](https://openclaw.ai)
### ⭐ Give us a Star · 🍴 Fork us · 💬 Join our Discord · 🐦 Follow on X
@@ -45,7 +46,7 @@ Semantica is the **context and intelligence layer** you add on top of your exist
-**Reasoning Engines** — forward chaining, Rete networks, deductive, abductive, and SPARQL. Explainable paths, not black boxes.
-**Quality & Deduplication** — conflict detection, entity resolution, and pipeline validation built in.
> Works alongside LangChain, LlamaIndex, AutoGen, CrewAI, and any LLM — Semantica is the **accountability layer** on top, not a replacement.
> Works alongside **Agno** and any LLM — Semantica is the **accountability layer** on top, not a replacement. LangChain, LangGraph, CrewAI, and more coming soon.
```bash
pip install semantica
@@ -53,17 +54,314 @@ pip install semantica
---
## Plugins (Claude, Cursor, Codex)
## 🔌 Works With Every AI Tool
Semantica includes a cross-platform plugin bundle under `plugins/` for community use:
Semantica ships **native plugin bundles** for Claude Code, Cursor, and Codex, an **MCP server** (`python -m semantica.mcp_server`) for Windsurf, Cline, Continue, VS Code, Claude Desktop, and OpenClaw, and a **REST API** (FastAPI, port 8000) for any other tool.
- 17 domain skills (context graphs, decision intelligence, explainability, reasoning, provenance, ontology, temporal, visualization)
- Specialized agents (`decision-advisor`, `explainability`, `kg-assistant`)
- Hook configuration and platform-specific manifests for Claude, Cursor, and Codex
<table>
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.triplet_store` | RDF triplet store integration — Blazegraph, Jena, RDF4J; SPARQL queries and bulk loading |
| `semantica.visualization` | Interactive and static visualization of KGs, ontologies, embeddings, analytics, and temporal graphs |
| [`explorer/`](explorer/) | **Semantica Knowledge Explorer** — React 19 + Sigma.js UI: graph canvas, decision viewer, causal chains, entity resolution, ontology browser, and registry audit log |
| `semantica.seed` | Seed data management for initial KG construction from CSV, JSON, databases, and APIs |
| `semantica.core` | Framework orchestration, configuration management, knowledge base construction, plugin system |
| `semantica.llms` | LLM provider integrations — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM |
@@ -643,16 +942,13 @@ if result.valid:
- **`semantica.triplet_store`** — Blazegraph, Jena, RDF4J; SPARQL, bulk loading, SKOS helpers
- **`semantica.visualization`** — KG, ontology, embedding, and temporal graph visualization
- **`semantica.llms`** — Groq, OpenAI, Novita AI, HuggingFace, LiteLLM
---
## 🔌 Integrations
- **[`explorer/`](explorer/)** — **Semantica Knowledge Explorer** — browser UI for live graph inspection, decisions, entity resolution, and ontology browsing (`npm run dev` in `explorer/`)
### Graph Databases
- **AWS Neptune** — Amazon Neptune with IAM authentication
- **Neo4j** — Cypher queries via `semantica.graph_store`
- **FalkorDB** — native support; `DecisionQuery` and `CausalChainAnalyzer` work directly with FalkorDB row/header shapes
- **Apache AGE** — PostgreSQL + openCypher via SQL
- **FalkorDB** — native support for decision queries and causal analysis
- **AWS Neptune** — Amazon Neptune with IAM authentication
### Vector Databases
- **FAISS** — built-in, zero extra dependencies
@@ -668,22 +964,15 @@ if result.valid:
- **Databases** — SQL via `DBIngestor`
- **Snowflake** — table/query ingestion, pagination, password/key-pair/OAuth/SSO auth · `pip install semantica[db-snowflake]`
- **Docling** — advanced table and layout extraction (PDF, DOCX, PPTX, XLSX)
- **Email** — inbox ingestion via `EmailIngestor`
- **Repositories** — Git repo ingestion for code graph construction
### LLM Providers
- **LiteLLM** — 100+ models: OpenAI, Anthropic, Cohere, Mistral, Ollama, Azure, AWS Bedrock, and more
- **Novita AI** — OpenAI-compatible (`deepseek/deepseek-v3.2` and more) · set `NOVITA_API_KEY`
### Agentic Frameworks
Semantica complements — not replaces — LangChain, LlamaIndex, AutoGen, CrewAI, Google ADK, and more.
> **Agno — First-Class Integration** · `pip install semantica[agno]`
>
> Five ready-to-use Agno components:
> - `AgnoContextStore` — graph-backed agent memory
> - `AgnoKnowledgeGraph` — multi-hop GraphRAG knowledge base
> - `AgnoDecisionKit` — 6 decision-intelligence tools
> - `AgnoKGToolkit` — 7 KG pipeline tools
> - `AgnoSharedContext` — shared context graph for multi-agent teams
- **Groq** — ultra-low latency inference · set `GROQ_API_KEY`
- **HuggingFace** — local and hosted models via `HuggingFaceProvider`
- **Ollama** — local models including remote server support
---
-282
View File
@@ -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`.
- **1825% faster** batch processing with prefilter enabled (`prefilter_enabled=False` by default).
**Semantic Relationship Deduplication v2** (PR #340, by @ZohaibHassan16, fixes by @KaifAhmad1)
- Canonicalisation engine with predicate synonym mapping (`works_for``employed_by`).
- O(1) hash matching for exact canonical signatures.
- Weighted scoring: 60% predicate + 40% object composition with explainable `semantic_match_score`.
- **6.98x faster** than legacy mode (83ms vs 579ms).
- `dedup_triplets()` infinite recursion bug fixed; function is now a first-class API in `methods.py`.
**Deduplication v2 Migration Guide** (PR #344, by @ZohaibHassan16, fixes by @KaifAhmad1)
- Comprehensive `MIGRATION_V2.md` documenting all v2 strategies with code examples.
- Full backward compatibility maintained — legacy mode remains the default.
### Export Formats
**ArangoDB AQL Export** (PR #342, by @tibisabau)
- Full AQL INSERT statement generation for vertices and edges.
- Configurable collection names with validation and sanitisation; batch processing (default: 1000).
- `export_arango()` convenience function; `.aql` auto-detection in the unified exporter.
- 17 tests, 100% pass rate.
**Apache Parquet Export** (PR #343, by @tibisabau)
- Columnar storage format with configurable compression: snappy, gzip, brotli, zstd, lz4, none.
- Explicit Apache Arrow schemas with type safety; field normalisation for varied naming conventions.
- `export_parquet()` convenience function; `.parquet` auto-detection.
- Analytics-ready for pandas, Spark, Snowflake, BigQuery, Databricks.
- 25 tests, 100% pass rate.
### Bug Fixes & Test Suite Stabilisation
**Test Suite Fixes** (by @KaifAhmad1)
Context module:
- `retrieve_decision_precedents` — gated entity extraction on `use_hybrid_search=True` correctly.
- `_extract_entities_from_query` — now uses `word[0].isupper()` to capture camelCase identifiers like `CreditCard`.
- Added missing `expand_context` (BFS traversal) and `_get_decision_query` methods.
- Fixed `hybrid_retrieval`, `dynamic_context_traversal`, and `multi_hop_context_assembly` for correct single-pass BFS.
- Fixed `_retrieve_from_vector` fallback to `result["metadata"]["content"]` to prevent empty content and negative re-ranking scores.
KG module:
- `calculate_pagerank` — added `alpha`/`max_iter` aliases; return format changed to `{"centrality": scores, "rankings": sorted_list}`.
- `community_detector._to_networkx` — now returns a NetworkX graph directly when one is passed (previously lost all edges).
- Added 9 domain-specific tracking methods to `AlgorithmTrackerWithProvenance`.
- Created `provenance_tracker.py` with `ProvenanceTracker` (`track_entity`, `get_all_sources`, `clear`).
Pipeline module:
- Retry loop fixed — now correctly iterates to `max_retries`.
- `RecoveryAction` dataclass and `handle_failure(error, policy, retry_count)` added with LINEAR, EXPONENTIAL, and FIXED strategies.
- `add_step()` fixed to return the created `PipelineStep`.
- `validate` added as alias for `validate_pipeline` in `PipelineValidator`.
Other:
- Fixed `NameError` for missing `Type` import in `utils/helpers.py`.
- Vector store performance threshold relaxed (100ms → 500ms per decision for development machines).
- Windows cp1252 encoding fix in test files (emoji → ASCII).
- `ProvenanceTracker` added to `semantica/kg/__init__.py` exports.
**Results: ~840 tests passing, 36 skipped (external services), 0 failed**
---
## v0.3.0-alpha — Alpha (2026-02-19)
### Context & Decision Intelligence
**Context Engineering Enhancement** (PR #307, by @KaifAhmad1)
The foundational 0.3.0 feature — complete overhaul of the context module for production-grade decision intelligence:
- Full decision lifecycle: `record_decision()``add_decision()``add_causal_relationship()``trace_decision_chain()``analyze_decision_impact()``analyze_decision_influence()``find_similar_decisions()`
- `AgentContext` unified wrapper with granular feature flags: `decision_tracking`, `kg_algorithms`, `graph_expansion`; methods: `store()`, `retrieve()`, `get_conversation_history()`, `get_statistics()`, `capture_cross_system_inputs()`
- `AgentMemory` with working, conversation, and long-term memory tiers
- `PolicyEngine` with versioned policy nodes, compliance checking (`check_decision_rules()`), and `PolicyException` model
- Hybrid precedent search combining vector, structural, and category similarity with configurable weights
- Decision influence analysis via centrality measures and causal chain tracking
- GraphStore validation preventing runtime failures; secure logging
- 9 critical bug fixes across logging, security, audit trails, API compatibility, Cypher queries, centrality access, validation
**Context Decision Tracking Fixes** (PR #315, by @KaifAhmad1)
- Fixed empty/None decision ID handling in `add_decision()`
- Fixed None metadata handling preventing `TypeError`
- Fixed causal chain depth logic and node exclusion
- Fixed nonexistent node handling in `add_causal_relationship()`
- Fixed precedent search direction in `find_precedents()`
- Added missing `properties` field in `to_dict()`; added `from_dict()` method
- Fixed UUID generation across all decision models
- All 71 context tests passing
### Knowledge Graph Algorithms
**Improved Graph Algorithms** (PR #292, by @KaifAhmad1)
- 30+ graph algorithms across 7 categories
- Node embeddings: Node2Vec, DeepWalk, Word2Vec via `NodeEmbedder`
- Similarity: cosine, Euclidean, Manhattan, Correlation via `SimilarityCalculator`
- Path finding: Dijkstra, A*, BFS, K-shortest paths via `PathFinder`
- Link prediction: preferential attachment, Jaccard, Adamic-Adar via `LinkPredictor`
- Centrality: degree, betweenness, closeness, PageRank via `CentralityAnalyzer`
- Community detection: Louvain, Leiden, label propagation via `CommunityDetector`
- Connectivity: components, bridges, density via `ConnectivityAnalyzer`
- `GraphBuilderWithProvenance` and `AlgorithmTrackerWithProvenance` with full execution metadata
**Improved Vector Store for Decision Tracking** (PR #293, by @KaifAhmad1)
- `DecisionEmbeddingPipeline` with semantic and structural embeddings
- `HybridSimilarityCalculator` with configurable weights (semantic: 0.7, structural: 0.3)
- `ContextRetriever` with multi-hop reasoning
- Convenience API: `quick_decision()`, `find_precedents()`, `explain()`, `similar_to()`, `batch_decisions()`, `filter_decisions()`
- 34+ tests; performance: 0.028s per decision, 0.031s search, ~0.8KB memory per decision
### Graph Database Backends
**Apache AGE Backend Security Fixes** (PR #311, by @Sameer6305, fixes by @KaifAhmad1)
- `AgeStore` class with `GraphStore` API compatibility (openCypher via SQL on PostgreSQL)
- SQL injection vulnerabilities fixed with input validation
- psycopg2-binary dependency and migration guide added
- Fixed parameter replacement and test mock leakage
**PgVector Store Support** (PR #303, by @Sameer6305, @KaifAhmad1)
- Native PostgreSQL vector storage using the pgvector extension
- Multiple distance metrics: cosine, L2/Euclidean, inner product
- HNSW and IVFFlat indexing for approximate nearest neighbour search
- JSONB metadata storage with flexible filtering; batch operations
- Connection pooling with psycopg3/psycopg2 fallback
- SQL injection protection via `psycopg_sql.SQL()`; idempotent index and table management
- 36+ tests with Docker integration
### Infrastructure
**ResourceScheduler Deadlock Fix** (PR #299, #301, by @d4ndr4d3, @KaifAhmad1)
- Replaced `threading.Lock()` with `threading.RLock()` to fix nested lock acquisition deadlock in `allocate_resources()`
- Added `ValidationError` when no resources can be allocated
- Progress tracking updates moved outside lock scope
- 6 regression tests for deadlock prevention
**Security Configuration** (by @KaifAhmad1)
- Dependabot bi-weekly security updates with manual review
- Automated security scans (Bandit, Safety, Semgrep) on schedule
- Security-critical package grouping; zero auto-merge policy
---
## Summary by the Numbers
| Metric | Value |
|--------|-------|
| Total tests passing | **886+** |
| Test failures | **0** |
| Context tests | 335 |
| KG tests | ~430 |
| Semantic extraction tests | 70 (9 skipped — external LLM APIs) |
| Reasoning tests | 19 |
| Real-world scenario tests | 85 |
| PyPI classifier | Production/Stable |
| Python support | 3.8 3.12 |
---
## Upgrade
```bash
pip install --upgrade semantica
```
No breaking changes. All new parameters have safe defaults and all new methods are additive.
See [CHANGELOG.md](CHANGELOG.md) for the full line-by-line diff.
-105
View File
@@ -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)
+197
View File
@@ -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" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>semantica-explorer</title>
<title>Semantica Knowledge Explorer</title>
</head>
<body>
<div id="root"></div>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"name": "semantica-explorer",
"name": "semantica-knowledge-explorer",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -33,19 +34,19 @@
"devDependencies": {
"@babel/core": "^7.29.0",
"@eslint/js": "^9.39.4",
"@rolldown/plugin-babel": "^0.2.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^4.3.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^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 SparqlWorkspace = lazy(() => import('./workspaces/SparqlWorkspace/SparqlWorkspace').then((module) => ({ default: module.SparqlWorkspace })));
const VocabularyWorkspace = lazy(() => import('./workspaces/VocabularyWorkspace/VocabularyWorkspace').then((module) => ({ default: module.VocabularyWorkspace })));
const RegistryTab = lazy(() => import('./workspaces/EnrichWorkspace/RegistryTab').then((module) => ({ default: module.RegistryTab })));
const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/EntityResolutionTab').then((module) => ({ default: module.EntityResolutionTab })));
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
type ManageView = 'lineage' | 'kg-overview' | 'ontology';
type NavItem = {
id: WorkspaceId;
@@ -26,7 +31,7 @@ type NavItem = {
const queryClient = new QueryClient();
const navItems: NavItem[] = [
{ id: 'explore', label: 'Explore', hint: 'Graph and vocabulary browsing', icon: Database },
{ id: 'explore', label: 'Knowledge Explorer', hint: 'Graph and vocabulary browsing', icon: Database },
{ id: 'analyze', label: 'Analyze', hint: 'Query and inspect the dataset', icon: FileSearch },
{ id: 'decisions', label: 'Decisions', hint: 'Decision chains and precedent review', icon: Scale },
{ id: 'enrich', label: 'Enrich', hint: 'Import, export, and merge workflows', icon: GitBranchPlus },
@@ -275,19 +280,21 @@ function WorkspaceShell({
subtitle,
tabs,
compact = false,
kicker = 'Workspace',
children,
}: {
title: string;
subtitle?: string;
tabs?: ReactNode;
compact?: boolean;
kicker?: string;
children: ReactNode;
}) {
return (
<section className="workspace-shell">
<header className={`workspace-header${compact ? " workspace-header--compact" : ""}`}>
<div className="workspace-header-main">
<div className="workspace-kicker">Workspace</div>
<div className="workspace-kicker">{kicker}</div>
<div className="workspace-title-block">
<h1 className="workspace-title">{title}</h1>
{subtitle ? <div className="workspace-subtitle">{subtitle}</div> : null}
@@ -304,18 +311,45 @@ function WorkspaceFallback() {
return <div className="workspace-loading">Loading workspace</div>;
}
function WelcomeScreen() {
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-muted)',
}}>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
Welcome to Semantica
</h1>
<p style={{ margin: 0, fontSize: 14 }}>
Select a workspace from the sidebar to get started.
</p>
</div>
);
}
export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage');
const renderWorkspace = () => {
if (activeWorkspace === 'welcome') {
return <WelcomeScreen />;
}
if (activeWorkspace === 'explore') {
return (
<WorkspaceShell
title="Explore"
subtitle={exploreView === 'graph' ? undefined : "Browse the graph and switch views without leaving the workspace."}
kicker={exploreView === 'graph' ? 'Graph Studio' : 'Vocabulary Browser'}
compact
tabs={
<>
@@ -340,6 +374,7 @@ export default function App() {
<WorkspaceShell
title="Analyze"
subtitle="Query the active graph and test inference rules."
kicker={analyzeView === 'reasoning' ? 'Reasoning Engine' : 'SPARQL Query'}
tabs={
<>
<button className="workspace-tab" data-active={analyzeView === 'reasoning'} onClick={() => setAnalyzeView('reasoning')}>
@@ -363,6 +398,7 @@ export default function App() {
<WorkspaceShell
title="Decisions"
subtitle="Inspect decision chains, causal context, and precedent matches."
kicker="Decision Intelligence"
>
<Suspense fallback={<WorkspaceFallback />}>
<DecisionWorkspace />
@@ -375,7 +411,8 @@ export default function App() {
return (
<WorkspaceShell
title="Enrich"
subtitle="Import, export, and reconcile graph entities."
subtitle="Import, export, reconcile, and audit graph entities."
kicker="Knowledge Audit"
tabs={
<>
<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')}>
Diff and Merge
</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 />}>
{enrichView === 'import' ? <ImportExportWorkspace /> : <DiffMergeWorkspace />}
{enrichView === 'import' ? <ImportExportWorkspace /> :
enrichView === 'merge' ? <DiffMergeWorkspace /> :
enrichView === 'resolve' ? <EntityResolutionTab /> :
<RegistryTab />}
</Suspense>
</WorkspaceShell>
);
@@ -397,10 +443,29 @@ export default function App() {
return (
<WorkspaceShell
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 />}>
<LineageDiagram />
{manageView === 'lineage' ? <LineageDiagram /> :
manageView === 'kg-overview' ? <KGOverviewTab /> :
<OntologySummaryTab onOpenVocabularyBrowser={() => {
setActiveWorkspace('explore');
setExploreView('vocabulary');
}} />}
</Suspense>
</WorkspaceShell>
);
@@ -411,7 +476,7 @@ export default function App() {
<style>{shellStyles}</style>
<div className="app-shell">
<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 }) => (
<button
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 {
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;
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
highlighted?: boolean;
communityId?: string;
isCommunityGroup?: boolean;
memberCount?: number;
anchorNodeId?: string | null;
nodeType: string;
content: string;
@@ -74,6 +78,12 @@ export interface EdgeAttributes {
parallelIndex?: number;
parallelCount?: number;
familySize?: number;
rawEdgeIds?: string[];
isAggregated?: boolean;
aggregateCount?: number;
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
edgeType: string;
+77
View File
@@ -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
*/
import { useState } from "react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
@@ -29,6 +30,11 @@ export function DiffMergeWorkspace() {
const data = await res.json();
if (data.merged_into) {
setMsg(`Merge success: redirected ${data.edges_updated} edges to ${data.merged_into}`);
logEvent("merge", `Merged ${duplicateId}${data.merged_into} · ${data.edges_updated} edges redirected`, {
primary: data.merged_into,
duplicate: duplicateId,
edgesUpdated: data.edges_updated,
});
} else {
setMsg("Merge failed...");
}
@@ -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,
};
File diff suppressed because it is too large Load Diff
@@ -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 { SigmaSceneAdapter } from "./SigmaSceneAdapter";
import { createGraphLoadProgress } from "./graphLoading";
import { resolveDisplayGraph } from "./graphSceneState";
import {
chooseColorAccessor,
colorForNodeKey,
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
lensMode: "neighborhood",
effectQuality: "bounded",
};
const EMPTY_PATH: string[] = [];
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
@@ -67,6 +69,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
valid_until: attributes.valid_until ?? null,
properties: attributes.properties ?? {},
neighborCount: graph.neighbors(nodeId).length,
visibleNeighborCount: graph.neighbors(nodeId).length,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
};
}
@@ -113,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const [graphVersion, setGraphVersion] = useState(0);
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
const displayResult = useMemo(
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
[activePath, graphVersion, selectedNodeId, viewMode],
);
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
@@ -447,9 +457,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
<SigmaSceneAdapter
ref={sceneRef}
onNodeSelect={onNodeSelect}
graphVersion={graphVersion}
graphReady={Boolean(snapshot)}
displayGraph={displayResult.graph}
displayMeta={displayResult.meta}
displayState={displayResult.state}
selectedEdgeId=""
selectedNodeId={selectedNodeId}
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
activePath={activePath}
activePathEdgeIds={EMPTY_PATH}
effectsState={STAGE_EFFECTS_STATE}
isLayoutRunning={isLayoutRunning}
onLayoutRunningChange={onLayoutRunningChange}
@@ -33,6 +33,8 @@ type LinkPrediction = {
type PathResponse = {
path: GraphPath;
total_weight: number;
hop_count: number;
distance_band: "direct" | "near" | "mid-range" | "distant";
};
type TemporalBounds = {
@@ -137,6 +139,10 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
valid_until: node.valid_until ?? null,
properties: node.properties ?? {},
neighborCount,
visibleNeighborCount: neighborCount,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: neighborCount > 8,
};
}
@@ -425,6 +431,10 @@ export function GraphWorkspaceShell() {
valid_until: null,
properties: searchNode.properties ?? {},
neighborCount: 0,
visibleNeighborCount: 0,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: false,
}
: null;
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
@@ -553,6 +563,19 @@ export function GraphWorkspaceShell() {
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
}, [viewMode, visibleSelectedNode]);
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
if (nextViewMode === "focused") {
if (!selectedNodeId) {
return;
}
setViewMode("focused");
setIsLayoutRunning(false);
return;
}
setViewMode("full");
}, [selectedNodeId]);
const showLoadingOverlay =
isLoading
|| isFetching
@@ -629,8 +652,8 @@ export function GraphWorkspaceShell() {
<div className="graph-toggle-cluster">
{selectedNodeId ? (
<>
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
<button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
<button onClick={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
</>
) : (
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
@@ -24,6 +24,8 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
useImperativeHandle(ref, () => ({
fitView: () => canvasRef.current?.fitView(),
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
zoomIn: () => canvasRef.current?.zoomIn(),
zoomOut: () => canvasRef.current?.zoomOut(),
getRuntime: () => runtimeRef.current,
setLayoutRunning: onLayoutRunningChange
? (running: boolean) => {
@@ -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 =
| { type: "fitView" }
| { type: "focusNode"; nodeId: string };
| { type: "focusNode"; nodeId: string }
| { type: "centerSelection"; nodeId: string }
| { type: "centerGroupedSelection"; nodeId: string };
export interface GraphBehaviorContext {
sigma: Sigma;
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
onNodeSelectionChange: (nodeId: string) => void;
onEdgeSelectionChange: (edgeId: string) => void;
focusNodeInView: (nodeId: string) => void;
centerSelectionInView: (nodeId: string) => void;
centerGroupedSelectionInView: (nodeId: string) => void;
fitCurrentView: () => void;
dispatchAction: (action: GraphBehaviorActionRequest) => void;
}
@@ -1,7 +1,8 @@
import type { GraphBehavior } from "./types";
import type { GraphViewMode } from "../types";
export function createViewModeSwitchBehavior(): GraphBehavior {
let lastViewMode: "focused" | "full" | null = null;
let lastViewMode: GraphViewMode | null = null;
return {
id: "view-mode-switch",
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
}
lastViewMode = interactionState.viewMode;
const nextFocusedNodeId = interactionState.focusedNodeId;
if (interactionState.focusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
return;
}
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
export interface GraphTheme {
palette: {
@@ -190,6 +191,35 @@ export interface GraphTheme {
motion: {
cameraMs: number;
};
grouped: {
initialLayout: {
innerRadius: number;
ringSpacing: number;
minNodeSpacing: number;
nodePadding: number;
overlapIterations: number;
primaryLabelCount: number;
};
style: {
nodeSizeScale: number;
nodeBorderBoost: number;
fillAlpha: number;
shellAlpha: number;
edgeSizeScale: number;
edgeAlpha: number;
glowAlpha: number;
edgeVisibilityRatio: number;
topIncidentEdges: number;
};
layout: {
iterations: number;
gravity: number;
scalingRatio: number;
edgeWeightInfluence: number;
slowDown: number;
settleMs: number;
};
};
effects: {
pathPulse: {
minZoomTier: GraphZoomTier;
@@ -265,16 +295,16 @@ export const GRAPH_THEME: GraphTheme = {
],
overview: {
nodeBase: "#0B1320",
nodeCore: "#435D7A",
nodeCore: "#5A7A9E",
nodeMuted: "#121927",
nodeBorder: "#64758C",
nodeTintMix: 0.03,
nodeCoreMix: 0.52,
nodeBorder: "#7A92AE",
nodeTintMix: 0.14,
nodeCoreMix: 0.72,
nodeShellAlpha: 0.97,
nodeCoreAlpha: 1,
edgeBackbone: "rgba(83, 111, 148, 0.04)",
edgeStructure: "rgba(72, 90, 118, 0.009)",
edgeInspection: "rgba(98, 120, 148, 0.026)",
edgeBackbone: "rgba(100, 148, 210, 0.38)",
edgeStructure: "rgba(88, 140, 200, 0.28)",
edgeInspection: "rgba(110, 165, 230, 0.48)",
},
accent: {
selected: "#F2D288",
@@ -285,12 +315,12 @@ export const GRAPH_THEME: GraphTheme = {
inferred: "#D07B4D",
},
muted: {
fallback: "rgba(96, 112, 136, 0.1)",
nodeAlpha: 0.085,
edgeOverview: "rgba(82, 100, 124, 0.009)",
edgeStructure: "rgba(92, 112, 138, 0.02)",
edgeInspection: "rgba(124, 148, 176, 0.066)",
edgeFocus: "rgba(160, 186, 218, 0.16)",
fallback: "rgba(96, 112, 136, 0.18)",
nodeAlpha: 0.12,
edgeOverview: "rgba(82, 100, 124, 0.12)",
edgeStructure: "rgba(92, 112, 138, 0.18)",
edgeInspection: "rgba(124, 148, 176, 0.26)",
edgeFocus: "rgba(160, 186, 218, 0.42)",
},
background: {
canvas: "#07101A",
@@ -305,43 +335,43 @@ export const GRAPH_THEME: GraphTheme = {
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.66,
labelThreshold: 0.985,
labelBudget: 10,
nodeScale: 0.72,
labelThreshold: 0.995,
labelBudget: 4,
edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.34,
edgeSizeScale: 0.62,
showBadges: false,
showCurves: false,
showContextualArrows: false,
},
structure: {
maxRatio: 1.2,
nodeScale: 0.98,
labelThreshold: 0.88,
labelBudget: 36,
nodeScale: 0.94,
labelThreshold: 0.93,
labelBudget: 18,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
showBadges: true,
showCurves: true,
showContextualArrows: true,
showBadges: false,
showCurves: false,
showContextualArrows: false,
},
inspection: {
maxRatio: 0.5,
nodeScale: 1,
labelThreshold: 0.7,
labelBudget: 80,
labelThreshold: 0.8,
labelBudget: 40,
edgePriorityThreshold: 0,
arrowPriorityThreshold: 0.58,
edgeSizeScale: 1.04,
arrowPriorityThreshold: 0.45,
edgeSizeScale: 1.18,
showBadges: true,
showCurves: true,
showContextualArrows: true,
},
},
labels: {
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
forceVisibleStates: ["hovered", "selected", "path"],
policies: {
none: { minZoomTier: "inspection" },
priority: { minZoomTier: "overview" },
@@ -391,7 +421,7 @@ export const GRAPH_THEME: GraphTheme = {
},
nodes: {
backgroundScale: 0.52,
mutedAlpha: 0.08,
mutedAlpha: 0.16,
strokeHierarchy: {
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
@@ -399,18 +429,18 @@ export const GRAPH_THEME: GraphTheme = {
},
states: {
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 },
selected: { color: "selected", sizeMultiplier: 1.06, minSize: 10.5, forceLabel: true, zIndex: 3, borderBoost: 0.2 },
neighbor: { color: "base", sizeMultiplier: 0.84, minSize: 4.8, forceLabel: true, zIndex: 2, borderBoost: -0.08 },
path: { color: "path", sizeMultiplier: 1.01, minSize: 6.2, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
inactive: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.32, minSize: 0.46, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
},
variants: {
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
},
selectedRing: {
@@ -437,14 +467,14 @@ export const GRAPH_THEME: GraphTheme = {
},
edges: {
states: {
default: { color: "structure", sizeMultiplier: 0.74, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 0.72, minSize: 0.18, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.45, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 0.92, minSize: 0.5, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.5, minSize: 1.8, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
muted: { color: "muted", sizeMultiplier: 1, minSize: 0.16, zIndex: 0, forceArrow: false, hide: true },
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
},
variants: {
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
@@ -473,6 +503,35 @@ export const GRAPH_THEME: GraphTheme = {
motion: {
cameraMs: 380,
},
grouped: {
initialLayout: {
innerRadius: 92,
ringSpacing: 138,
minNodeSpacing: 112,
nodePadding: 28,
overlapIterations: 18,
primaryLabelCount: 6,
},
style: {
nodeSizeScale: 0.9,
nodeBorderBoost: 0.42,
fillAlpha: 0.68,
shellAlpha: 0.28,
edgeSizeScale: 0.62,
edgeAlpha: 0.32,
glowAlpha: 0.14,
edgeVisibilityRatio: 0.18,
topIncidentEdges: 2,
},
layout: {
iterations: 18,
gravity: 0.06,
scalingRatio: 18,
edgeWeightInfluence: 0.08,
slowDown: 34,
settleMs: 1500,
},
},
effects: {
pathPulse: {
minZoomTier: "structure",
@@ -530,7 +589,7 @@ export const GRAPH_THEME: GraphTheme = {
maxGroups: 8,
},
diagnostics: {
enabledInDev: import.meta.env.DEV,
enabledInDev: IS_DEV,
},
},
};
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
}
const selected = context.getSelectedNodeState();
const displayState = context.getDisplayState();
if (!selected) {
return {
id: NEIGHBORHOOD_PANEL_ID,
@@ -82,6 +83,11 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
return left.label.localeCompare(right.label);
})
.slice(0, MAX_NEIGHBORS);
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
const aggregatedEdgeCount = context.displayGraph
.edges()
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
.filter((attrs) => attrs.isAggregated).length;
return {
id: NEIGHBORHOOD_PANEL_ID,
@@ -97,6 +103,34 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
<div style={summaryStyle}>
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Collapse Neighborhood
</button>
<button
type="button"
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
disabled={!selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Expand Neighborhood
</button>
</div>
{hiddenNeighborCount > 0 ? (
<div style={summaryStyle}>
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
</div>
) : null}
{aggregatedEdgeCount > 0 ? (
<div style={summaryStyle}>
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
</div>
) : null}
{neighbors.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{neighbors.map((neighbor) => (
@@ -159,6 +193,16 @@ const neighborButtonStyle: CSSProperties = {
cursor: "pointer",
};
const controlButtonStyle: CSSProperties = {
padding: "7px 10px",
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
color: "#dce7f4",
cursor: "pointer",
fontSize: 12,
};
const swatchStyle: CSSProperties = {
width: 10,
height: 10,
@@ -6,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
import type { GraphSceneRuntime } from "../scene";
import type {
GraphAnalyticsSnapshot,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphEffectToggle,
@@ -31,6 +32,8 @@ export type GraphPluginActionRequest =
| { type: "focusNode"; nodeId: string }
| { type: "selectNode"; nodeId: string }
| { type: "setViewMode"; viewMode: GraphViewMode }
| { type: "collapseNeighborhood" }
| { type: "expandNeighborhood" }
| { type: "toggleEffect"; effect: GraphEffectToggle }
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
| { type: "togglePanel"; panelId: string }
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
getEffectsState: () => GraphEffectsState;
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
getDisplayState: () => GraphDisplayStateSnapshot;
isPanelOpen: (panelId: string) => boolean;
dispatchAction: (action: GraphPluginActionRequest) => void;
}
@@ -5,6 +5,8 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
@@ -22,6 +24,8 @@ export interface GraphSceneRuntime {
scene: unknown;
graph: GraphSceneGraph;
displayGraph: GraphSceneGraph;
graphVersion: number;
layoutMode?: GraphDisplayMeta["layoutMode"];
requestRender: () => void;
getCameraState: () => GraphCameraState | null;
}
@@ -37,7 +41,13 @@ export interface GraphSceneEventMap {
}
export interface GraphSceneProps extends GraphSceneEventMap {
graphVersion: number;
graphReady: boolean;
displayGraph: GraphSceneGraph;
displayMeta: GraphDisplayMeta;
displayState?: GraphDisplayStateSnapshot;
selectedNodeId: string;
focusedNodeId: string;
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
@@ -56,6 +66,8 @@ export interface GraphSceneProps extends GraphSceneEventMap {
export interface GraphSceneHandle {
fitView: () => void;
focusNode: (nodeId: string) => void;
zoomIn: () => void;
zoomOut: () => void;
getRuntime: () => GraphSceneRuntime | null;
setLayoutRunning?: (running: boolean) => void;
}
@@ -1,4 +1,4 @@
export type GraphViewMode = "focused" | "full";
export type GraphViewMode = "focused" | "full" | "grouped";
export type GraphLayoutSource = "provided" | "carried" | "runtime";
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
export type GraphLoadPhase =
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
export type GraphLoadProgressKind = "determinate" | "indeterminate";
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
export interface GraphCameraState {
x: number;
@@ -31,6 +32,28 @@ export interface GraphInteractionState {
isLayoutRunning: boolean;
}
export interface GraphDisplayStateSnapshot {
aggregationEnabled: boolean;
groupedViewAvailable: boolean;
groupedViewReason: string | null;
selectedRootNodeId: string | null;
selectedVisibleNeighborIds: string[];
selectedCollapsedNeighborIds: string[];
selectedNodeKind: GraphSelectedNodeKind;
canActivateFocused: boolean;
resolvedFocusedNodeId: string | null;
focusedUnavailableReason: string | null;
}
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
export interface GraphDisplayMeta {
layoutMode: GraphDisplayLayoutMode;
positionSource: "store" | "display";
tracksStoreNodePositions: boolean;
hasSyntheticNodes: boolean;
}
export type GraphEffectToggle =
| "pathPulseEnabled"
| "pathFlowEnabled"
@@ -245,6 +268,10 @@ export interface GraphSelectedNodeState {
valid_until?: string | null;
properties: Record<string, unknown>;
neighborCount: number;
visibleNeighborCount: number;
collapsedNeighborCount: number;
isNeighborhoodCollapsed: boolean;
canCollapseNeighborhood: boolean;
}
export interface GraphSelectedEdgeState {
@@ -260,6 +287,12 @@ export interface GraphSelectedEdgeState {
provenanceCount: number;
familySize: number;
siblingCount: number;
isAggregated: boolean;
aggregateCount: number;
rawEdgeIds: string[];
bundleKind: "parallel" | "bidirectional" | "community" | null;
dominantEdgeType: string | null;
representativeWeight: number;
}
export interface GraphStageHandle {
@@ -4,6 +4,7 @@
import { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { UploadCloud, Download, FileJson, FileText, CheckCircle2, AlertCircle, Loader2 } from "lucide-react";
import { logEvent } from "../../store/registryStore";
const THEME_CSS = `
.glass-panel {
@@ -107,6 +108,11 @@ export function ImportExportWorkspace() {
const data = await res.json();
showToast("success", `Imported ${data.nodes_imported} nodes and ${data.edges_imported} edges!`);
logEvent("import", `Imported ${data.nodes_imported} nodes · ${data.edges_imported} edges from ${file.name}`, {
file: file.name,
nodesImported: data.nodes_imported,
edgesImported: data.edges_imported,
});
setFile(null);
} catch (err: any) {
showToast("error", err.message || "An error occurred during import");
@@ -142,6 +148,7 @@ export function ImportExportWorkspace() {
document.body.removeChild(a);
showToast("success", "Export complete! Your download should begin shortly.");
logEvent("export", `Exported graph as ${exportFormat.toUpperCase()}`, { format: exportFormat });
} catch (err: any) {
showToast("error", err.message || "An error occurred during export");
} finally {
@@ -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 react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
babel({ presets: [reactCompilerPreset()] })
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
base: '/',
+137
View File
@@ -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)
+57
View File
@@ -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"
+253
View File
@@ -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})"
+242
View File
@@ -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
```
+27
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
"""Entry point: python -m mcp.server"""
from mcp.server import main
if __name__ == "__main__":
main()
+8
View File
@@ -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"]
+153
View File
@@ -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)}),
}
+292
View File
@@ -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": {}}
+223
View File
@@ -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()
+47
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More