Compare commits

..
83 Commits
Author SHA1 Message Date
Mohd Kaif fbbe36983b Merge pull request #521 from Hawksight-AI/feat/ontology-hub-subissue-518
feat(explorer): Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager
2026-05-01 17:16:43 +05:30
KaifAhmad1 877903358a docs(changelog): add entries for ontology hub bug fixes and security advisory #23 2026-05-01 17:12:11 +05:30
KaifAhmad1 070b36902b fix(security): remove polynomial ReDoS regex in _detect_format (py/polynomial-redos)
The pattern `<[^>]+>\s+<[^>]+>` in _detect_format() was flagged by CodeQL
(py/polynomial-redos, CWE-1333/730/400) as a polynomial regular expression
on uncontrolled user data.

The `<...>` branch was already unreachable — strings starting with '<' return
'xml' two lines above — but CodeQL does not track that control flow path.

Fix: replace the entire re.match() call with plain startswith / 'in' checks:
- N-Triples with URI subjects are already handled by the XML branch.
- Only blank-node-subject N-Triples (_:word <uri> ...) need detection here,
  which is correctly expressed as startswith('_:') and ' <' in stripped.
- Removed the now-unused `import re`.

Closes security advisory #23.
2026-05-01 15:26:17 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 3b9efb7856 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-05-01 15:21:32 +05:30
KaifAhmad1 2a031f0225 fix(explorer): correct file upload format detection for xml/json extensions (#518)
Bug 4 — Upload format misdetected:
- Added xml→'xml' and json→'json-ld' to the extension→format map so
  .xml and .json files are no longer misidentified as turtle.
- Changed the fallback from '|| "turtle"' to '?? ""' (empty string for
  unknown extensions) so the backend _detect_format() runs instead of
  blindly assuming turtle for any unrecognised extension.
- Omit the format key entirely from the load request body when no format
  was detected, letting the backend auto-detect from content heuristics.
- Added .n3 to the file picker accept list and dropzone hint text.
2026-05-01 15:18:28 +05:30
KaifAhmad1 d04f2b3643 fix(explorer): address Qodo review findings for ontology hub (#518)
Bug 1 — Broken registry filters:
fetchRegistry no longer sends format/kind values (owl/skos/internal/external)
as the status query param; those filters are applied client-side via
filteredEntries which already had the correct logic. Only the text search
param q is delegated to the backend.

Bug 2 — Toggle/refresh URI corruption:
Removed removesuffix('/toggle') and removesuffix('/refresh') from
toggle_ontology and refresh_ontology. Starlette's route regex already
strips the literal suffix from the captured path param; the removesuffix
call was a no-op for normal URIs but corrupted any ontology URI that
legitimately ends with /toggle or /refresh.

Bug 3 — SSRF in URL fetch:
Added _validate_fetch_url() which rejects non-http/https schemes and
resolves the hostname to block private, loopback, link-local, reserved,
and multicast addresses before requests.get() is called. Applied to all
three fetch sites: preview, load, and refresh.

Bug 5 — Inconsistent XML hardening:
_parse_rdf_sync now calls _safe_parse_rdf() from
semantica/explorer/utils/rdf_parser.py instead of g.parse() directly,
applying the existing defusedxml-based XXE protection for RDF/XML inputs.

Bug 6 — Search scans whole graph:
search_entities now calls session.search(q, limit*6) which hits the
GraphSearchIndex instead of fetching up to 999,999 nodes and doing a
linear Python substring scan. Results are post-filtered by _SEARCHABLE_TYPES
and entity_type before being returned up to the requested limit.
2026-05-01 15:12:04 +05:30
KaifAhmad1 2811469071 feat(explorer): add Ontology Hub workspace — Registry, Loader, Entity Search & SKOS (closes #518)
Implements the first subissue of Ontology Hub (#517):

Frontend:
- New OntologyWorkspace with 6 tabs (Registry, Editor, Versions,
  Alignments, Health, SHACL); active tab persisted in ontologyTab URL param
- OntologyManager: full registry CRUD with status/format badges, stats,
  toggle/refresh/remove actions, search + filter toolbar, empty state CTA
- OntologyLoader: 3-tab modal — URL import with live preview, drag-and-drop
  file upload, and Create New (from scratch / data / text)
- OntologySearch: debounced entity search with type filters and detail panel
  showing superclasses, subclasses, domain/range, instance count
- SKOSVocabularyManager: recursive concept hierarchy tree, client-side
  filtering, full SKOS annotation + relation detail panel
- Editor/Versions (subissue 2) and Alignments/Health/SHACL (subissue 3)
  tabs render descriptive stub cards as placeholders

Backend:
- 12 new FastAPI endpoints under /api/ontology (registry, preview, load,
  create, search, entity detail, SKOS schemes + concept detail, toggle,
  refresh, remove)
- rdflib-based RDF parser supporting Turtle, RDF/XML, N-Triples, JSON-LD
- URL fetching via requests in asyncio.to_thread with 20 MB cap
- Registry stored in app.state.ontology_registry; route ordering prevents
  literal paths being shadowed by /{uri:path} wildcards

Also: add playwright dev dependency for screenshot testing
2026-05-01 13:08:45 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a0b4793590 security(deps): update pymdown-extensions requirement (#510)
Updates the requirements on [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) to permit the latest version.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.0...10.21.2)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: 10.21.2
  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-05-01 11:40:01 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cdeb04b816 security(deps): update mkdocs-material requirement (#511)
Updates the requirements on [mkdocs-material](https://github.com/squidfunk/mkdocs-material) to permit the latest version.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.4.0...9.7.6)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-version: 9.7.6
  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-05-01 11:33:24 +05:30
Mohd Kaif b7e31d82b0 Merge pull request #516 from Hawksight-AI/feat/landing-page-visual-refresh
feat(explorer): redesign landing page
2026-04-30 18:23:36 +05:30
Mohd Kaif 2eba54caac Merge branch 'main' into feat/landing-page-visual-refresh 2026-04-30 16:55:03 +05:30
KaifAhmad1andZohaib Hassnain 9fd1df9c51 docs(changelog): add entry for PR #516 landing page redesign and review fixes
Co-Authored-By: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-30 16:54:11 +05:30
KaifAhmad1andZohaib Hassnain 4517089a7f fix(explorer): address PR #516 review findings
- Replace invalid inset-left with inset: 0 0 0 72px on ::before at <=680px
- Add matching mobile inset fix to ::after (was still at 88px)
- Merge duplicate .landing-capability-band CSS rule blocks into one
- Fix non-standard font-weight: 850 -> 800 on .landing-launcher-item-title
- Remove unused eyebrow field from LandingAction type and all data entries
- Extract static 42-dot SVG preview array to module-level PREVIEW_DOTS constant

Co-Authored-By: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <kaifahmad087@gmail.com>
2026-04-30 16:50:31 +05:30
Zohaib Hassnain 9553e1a176 feat(explorer): redesign landing page 2026-04-29 23:38:01 +05:00
Mohd Kaif 04fcfb61a7 Merge pull request #515 from Hawksight-AI/feat/distance-intelligence-slash-safe-ui
fix(explorer): make distance intelligence API calls slash-safe
2026-04-29 23:30:12 +05:30
5dc6966706 docs(changelog): add entry for issue #514 / PR #515 slash-safe distance UI fix
Co-Authored-By: ZohaibHassan16 <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <98801504+KaifAhmad1@users.noreply.github.com>
2026-04-29 23:23:08 +05:30
bb956b2735 fix(explorer): address PR #515 review findings
- Align _coerce_embedding_vector inner dict-probe key list with
  _extract_node_embeddings outer key list (add 'embeddings', reorder to
  generic-first) so nested embedding dicts resolve consistently.
- Add TODO comment on _extract_node_embeddings to cache per-session
  graph revision and avoid O(N) re-scan on every semantic request.
- Add deprecation docstrings to legacy path-segment routes
  (/node/{id}/path and /node/{id}/semantic-neighborhood) documenting
  the known slash-in-ID limitation and pointing to the query-param
  alternatives.
- Extract _FakeSimilarity to module level so it is shared without
  duplication across test classes.
- Rewrite test_legacy_semantic_neighborhood_still_works_for_simple_ids
  as a fully isolated TestClient session instead of mutating the
  shared module-scoped 'client' fixture, preventing cross-test
  state pollution.
- Extract _make_slash_node_session helper to reduce boilerplate in the
  slash-safe route tests.

Co-Authored-By: ZohaibHassan16 <109234410+ZohaibHassan16@users.noreply.github.com>
Co-Authored-By: KaifAhmad1 <98801504+KaifAhmad1@users.noreply.github.com>
2026-04-29 23:17:57 +05:30
Zohaib Hassnain e3a3f6010b fix(explorer): make distance intelligence API calls slash-safe 2026-04-29 21:17:36 +05:00
Mohd Kaif b6373204e2 Merge pull request #513 from Hawksight-AI/feat/explorer-distance-ui-fix
fix(explorer): make distance intelligence visible
2026-04-29 15:32:02 +05:30
e385c78977 docs(changelog): add entry for PR #513 distance intelligence fix
Co-Authored-By: ZohaibHassan16 <zohaib@hawksight.ai>
Co-Authored-By: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-29 14:54:21 +05:30
7b581d5960 fix(explorer): address PR #513 review blockers
- Fix dead `if (anchorNodeId)` conditional in buildHeatmapRenderSnapshot
  (anchor is always truthy past the early-return guard on line 263)
- Replace O(n) array .includes() with WeakMap-cached Set.has() in
  resolveDistanceNodeStyle heatmap path — prevents per-node O(n) scan
  during every Sigma reducer pass on large graphs
- Rename GraphDistanceBucketCounts.threeHop → threeHopPlus across
  types.ts, graphSceneState.ts, and GraphWorkspace.tsx so the field
  name reflects that it accumulates distance ≥ 3, not exactly 3;
  update status-strip labels to "3+ hop" accordingly
- Restore hasMetrics guard in PathDistanceIntelPanel to suppress the
  empty metric grid <div> when a path result carries no optional metrics

Co-Authored-By: ZohaibHassan16 <zohaib@hawksight.ai>
Co-Authored-By: KaifAhmad1 <mohammadk78600@gmail.com>
2026-04-29 14:49:22 +05:30
Zohaib Hassnain 07ca93fe5c fix(explorer): make distance intelligence visible 2026-04-29 02:49:08 +05:00
Mohd Kaif 41e430b928 Merge pull request #503 from Hawksight-AI/feat/explorer-visual-refresh
feat(explorer): polish graph explorer visual language
2026-04-27 22:33:41 +05:30
438d8bc7af fix(explorer): address PR #503 review findings
- Extract ENTITY_SHAPE_ALIASES and classifyEntityShape into a shared
  graphEntityShape.ts utility — resolveEntityShape was duplicated with
  divergent signatures in useLoadGraph.ts and graphSceneState.ts; both
  now import from one place so aliases can never drift
- graphSceneState.resolveEntityShape falls back to classifyEntityShape
  for nodes created programmatically that bypass useLoadGraph
- Fix graphTheme.ts indentation around fullGraphStructure,
  fullGraphStructureLayer, and interaction — closing braces were at
  wrong indent levels making the nesting visually misleading
- Add comment on fullGraphStructureLayer.mode explaining it is
  intentionally "off" as a staged-rollout gate (flip to "auto" to enable
  cross-community canvas curve rendering)

Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-27 22:21:41 +05:30
82f1f6bd10 docs(changelog): add entry for Explorer visual refresh PR #503
Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-27 22:00:52 +05:30
c86570b996 merge(explorer-visual-refresh): resolve conflict in GraphWorkspace.tsx
Merge origin/main (Distance Intelligence #502) into feat/explorer-visual-refresh.

Conflict was in the viewModeItems useMemo: the PR's new cluster-based toolbar
structure diverged from main's coreToolbarGroups additions.

Resolution:
- Keep PR's viewModeItems as a clean 3-item segmented control (Full/Grouped/Focused)
- Port Distance Intelligence controls (ego mode, heatmap, structural/semantic overlay)
  into a new distanceToolbarItems useMemo that slots into the cluster toolbar as a
  "Distance" cluster, visible only when a node is selected
- Wire distanceToolbarItems into toolbarClusters between "local-structure" and
  "analysis" clusters
- All other Distance Intelligence additions (state vars, BFS helpers, useEffects,
  ego depth slider, GraphInspectorPanel onFocusNode prop) merged cleanly

Co-authored-by: Mohd Kaif <98801504+KaifAhmad1@users.noreply.github.com>
Co-authored-by: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com>
2026-04-27 21:55:25 +05:30
Mohd Kaif 93dda5e435 Merge pull request #512 from Hawksight-AI/context
feat(context): add distance intelligence across context, API, and Exp…
2026-04-27 20:12:40 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 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
Zohaib Hassnain 379994867d feat(explorer): polish graph explorer visual language 2026-04-27 03:10:28 +05:00
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]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 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]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 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]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 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 <223894421+github-code-quality[bot]@users.noreply.github.com> 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
77 changed files with 18311 additions and 1418 deletions
+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/
+147 -1
View File
@@ -7,8 +7,154 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **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: Ontology Hub post-review bug fixes and security hardening** (follow-up to #518, closes security advisory #23, by @KaifAhmad1):
- **Broken registry filters** — `fetchRegistry` was sending toolbar filter values (`owl`, `skos`, `internal`, `external`) to the backend as the `status` query param, which only accepts `published|draft|external`, causing those filters to return empty lists. Removed the spurious `status` param; all format/kind filtering is now applied client-side via `filteredEntries`, which already had the correct logic.
- **Toggle/refresh URI corruption** — `toggle_ontology` and `refresh_ontology` applied `.removesuffix("/toggle")` / `.removesuffix("/refresh")` to the captured path parameter, which would silently corrupt any ontology URI that legitimately ends with those strings. Starlette's route regex (`/{uri:path}/toggle`) already strips the literal suffix via backtracking, so the `removesuffix` calls were removed and the raw `ontology_uri` parameter is used directly.
- **SSRF in URL fetch** — `_fetch_url_sync()` accepted arbitrary user-supplied URLs and called `requests.get()` with no validation, enabling server-side request forgery against internal services. Added `_validate_fetch_url()` which rejects non-`http`/`https` schemes and resolves the hostname via `socket.getaddrinfo`, blocking loopback, private, link-local, reserved, and multicast addresses. Applied to all three fetch sites: preview, load, and refresh.
- **File upload format misdetected** — the file picker accepted `.xml` and `.json` but `fmtMap` had no entries for those extensions, causing them to default to `turtle`. Added `xml: "xml"` and `json: "json-ld"` mappings. Changed the unknown-extension fallback from `|| "turtle"` to `?? ""` (empty string), and omit the `format` key from the request body when empty so the backend `_detect_format()` runs instead of receiving a forced incorrect value. Also added `.n3` to the accepted extension list and dropzone hint.
- **Inconsistent XML hardening** — `_parse_rdf_sync()` called `rdflib.Graph().parse()` directly, bypassing the `defusedxml`-based XXE protection already present in `semantica/explorer/utils/rdf_parser.py`. Now routes through `_safe_parse_rdf()` from that module, applying consistent protection for all RDF/XML parse paths.
- **Search scans whole graph** (`GET /api/ontology/search`) — the endpoint fetched up to 999 999 nodes and performed a linear Python substring scan on every request. Replaced with `session.search(q, limit * 6)` which uses the `GraphSearchIndex`; results are then post-filtered by `_SEARCHABLE_TYPES` and `entity_type` before being returned up to the requested limit.
- **ReDoS in format detector** (security advisory #23, CodeQL `py/polynomial-redos`, CWE-1333/730/400) — `_detect_format()` used `re.match(r"_:\w+|<[^>]+>\s+<[^>]+>", ...)` to detect N-Triples content. The `<[^>]+>\s+<[^>]+>` alternative was flagged as a polynomial regular expression on uncontrolled data. The URI-subject branch was already unreachable (strings starting with `<` return `"xml"` two lines above), so the entire regex was replaced with two O(1) string operations: `stripped.startswith("_:")` and `" <" in stripped`. `import re` removed as now unused.
- **Feature: Ontology Hub — Registry, Loader, Entity Search & SKOS Vocabulary Manager** (closes #518, part of #517, by @KaifAhmad1):
- Added a sixth workspace, **Ontology Hub** (`ontology-hub`), to the Knowledge Explorer sidebar with a `GitMerge` icon and "Schema Governance" kicker. The workspace shell hosts six tabs — Registry, Editor, Versions, Alignments, Health, and SHACL — with the active tab persisted in the `ontologyTab` URL search parameter via `window.history.replaceState`.
- **Registry tab (`OntologyManager`)** — full CRUD interface for loaded ontologies. Lists entries with color-coded status badges (published / draft / external), format badges (Turtle / XML / JSON-LD / N-Triples), per-ontology stats (class count, concept count, property count), source URL link, and enable/disable toggle, refresh, and remove (with confirmation) actions. Toolbar provides a live search input, All / OWL / SKOS / INTERNAL / EXTERNAL filter pills, an Entity Search button, and a "Load Ontology" button. Empty state surfaces a prominent CTA. Action feedback bar auto-hides after 3 seconds.
- **Load Ontology modal (`OntologyLoader`)** — three-tab modal overlay for importing ontologies:
- *URL Import*: paste any HTTP(S) URL, click "Fetch Preview" to call `POST /api/ontology/preview` (fetches up to 20 MB, parses with rdflib, returns title / namespace / version / license / format / triple count), then "Load Ontology" (`POST /api/ontology/load`). Advanced options toggle exposes format override, custom display name, description, and tags fields.
- *File Upload*: drag-and-drop zone (or browse) accepting `.ttl`, `.rdf`, `.owl`, `.nt`, `.jsonld` files; format auto-detected from extension; multipart `POST /api/ontology/load`.
- *Create New*: three modes — From Scratch (namespace + name + description + tags), From Data (sample data textarea for schema inference via `OntologyEngine.from_data()`), From Text (free-text textarea for LLM-assisted schema generation via `OntologyEngine.from_text()`); calls `POST /api/ontology/create`.
- **Entity Search panel (`OntologySearch`)** — slide-in right panel with debounced 320 ms search across all loaded ontologies via `GET /api/ontology/search`. Type filter pills: All, Class, Property, Individual, Concept, Scheme. Result rows show label, type badge, URI, definition snippet, and source ontology. Selecting a result opens a detail panel that fetches `GET /api/ontology/entity/{uri}` and renders label, URI, definition, superclasses, subclasses, domain, range, instance count, and external URI link. Long lists use a `CollapsibleList` expanding up to 12 items.
- **SKOS Vocabulary Manager (`SKOSVocabularyManager`)** — hierarchical SKOS concept browser activated when a SKOS ontology is selected in the registry. Fetches scheme hierarchy from `GET /api/vocabulary/hierarchy`, renders a recursive `ConceptTreeNode` tree with depth-based indentation, expand/collapse, and selection highlight. Client-side `filterConcepts()` matches label, altLabels, and description. Detail panel fetches `GET /api/ontology/skos/concept/{uri}` and displays all SKOS annotation properties (definition, scopeNote, example, historyNote, editorialNote, changeNote) plus broader / narrower / related / exactMatch / closeMatch lists with clickable navigation.
- **Backend (`semantica/explorer/routes/ontology.py`)** — 12 FastAPI endpoints under `GET|POST /api/ontology`:
- `GET /registry` — returns the in-memory `app.state.ontology_registry` dict as a list, with optional `q` search and `status` filter query params.
- `POST /preview` — streams up to 20 MB from a URL via `requests.get` in `asyncio.to_thread`, parses RDF with rdflib (auto-detects format or accepts `format` param), returns `OntologyPreview` metadata.
- `POST /load` — URL or multipart file load; stores parsed nodes/edges into the active graph session and registers an `OntologyEntry` in the registry.
- `POST /create` — creates an ontology from scratch, sample data, or natural-language text; falls back to a minimal ontology shell if `OntologyEngine` is unavailable.
- `GET /search` — full-text entity search with optional `type` filter across all nodes whose `node_type` maps to class, property, individual, concept, or scheme.
- `GET /entity/{uri:path}` — entity detail: label, type, definition, superclasses, subclasses, domain, range, instance count.
- `GET /skos/schemes` — lists all `skos:ConceptScheme` nodes in the active session.
- `GET /skos/concept/{uri:path}` — full SKOS concept detail including all annotation properties and relation sets.
- `DELETE /{uri:path}`, `PATCH /{uri:path}/toggle`, `POST /{uri:path}/refresh` — remove, enable/disable toggle, and re-fetch/re-parse for registered ontologies. Route ordering places all literal paths before the `:path` wildcards to avoid shadowing.
- Helper internals: `_parse_rdf_sync()` (rdflib parse → nodes/edges/metadata), `_fetch_url_sync()` (streaming requests with 20 MB cap), `_classify_node_type()` (maps raw RDF types to canonical categories), `_uri_to_prefix()` (URI → prefixed form for display).
- Editor, Versions (Subissue 2) and Alignments, Health, SHACL (Subissue 3) tabs render descriptive stub cards with amber subissue badges as placeholders for upcoming implementations.
- TypeScript compiled with zero errors; Vite dev server starts cleanly with the new workspace lazy-loaded via `React.lazy` + `Suspense`.
- **Feature: Explorer landing page redesign** (PR #516 by @ZohaibHassan16, review fixes by @KaifAhmad1):
- Replaced the plain welcome screen with a full landing composition: premium hero section, product preview mock with animated SVG graph, live graph status metrics, intelligence capability band, and consolidated workspace launcher.
- `WelcomeScreen` fetches `/api/graph/stats` on mount with `AbortController` cleanup and displays live node and edge counts; falls back to `"Live"` / `"Ready"` labels when the endpoint is unavailable.
- Workspace launcher surfaces Network Explorer as the primary path and provides direct one-click entry into Vocabulary, Analyze, Decisions, Enrich, and Manage workspaces.
- Added `LandingMetric`, `LandingAction`, and `GraphStatsPayload` TypeScript types; `getNumberStat` handles three API key shapes (`node_count`, `nodeCount`, `nodes` and equivalents) for forward-compatibility.
- Added `Space Grotesk` and `IBM Plex Sans` fonts (replacing `Inter`); `JetBrains Mono` used for kickers, badges, and metadata labels.
- Added `prefers-reduced-motion` media query suppressing `landing-float` animation and launcher hover transitions.
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): replaced invalid `inset-left` CSS property with `inset: 0 0 0 72px` on `.landing-page::before` in the `≤680px` breakpoint; added the same `inset` correction to `.landing-page::after` which was still offset at `88px` after rail narrowing; merged duplicate `.landing-capability-band` CSS rule blocks into one; corrected non-standard `font-weight: 850` to `800` on `.landing-launcher-item-title`; removed unused `eyebrow` field from `LandingAction` type and all data entries; extracted the static 42-dot SVG background array to a module-level `PREVIEW_DOTS` constant to avoid recomputing it on every render.
- **Fix: Semantic Distance UI slash-safe node IDs** (issue #514, PR #515 by @ZohaibHassan16, review fixes by @KaifAhmad1):
- **Root cause** — FastAPI decodes `%2F` before route matching, so node IDs containing `/` (e.g. `gene/protein:6164`) split the path-segment route and return 404. The frontend encoded slashes correctly but they were decoded server-side before the router matched the pattern.
- Added slash-safe query-param routes: `GET /api/graph/semantic-neighborhood?node_id=...` and `GET /api/graph/path?source=...&target=...`. Legacy path-segment routes (`/node/{id}/semantic-neighborhood`, `/node/{id}/path`) are kept as deprecated backward-compatible aliases with docstrings documenting the limitation.
- Frontend (`GraphWorkspace.tsx`, `GraphWorkspaceShell.tsx`) now builds all distance API calls via `URLSearchParams` so node IDs with slashes or other special characters are never embedded in URL path segments.
- `_semantic_neighborhood_impl` now returns HTTP 503 (instead of a silent 200 with zero neighbors) when semantic similarity is unavailable or the graph has no node embeddings, and HTTP 404 only when the anchor node itself does not exist. Frontend error messages updated to distinguish the two cases.
- Fixed a pre-existing bug where `find_most_similar` was called with `(graph_dict, node_id_string)` instead of the correct `(embeddings_dict, query_vector)` signature; added `_extract_node_embeddings` and `_coerce_embedding_vector` helpers to build the embeddings dict before the call.
- **Review fixes** (follow-up by @KaifAhmad1 and @ZohaibHassan16): aligned `_coerce_embedding_vector` inner dict-probe key list (added `"embeddings"`, reordered generic-first) with `_extract_node_embeddings` outer key list; added `TODO` comment on per-session embedding cache; extracted `_FakeSimilarity` test stub to module level to eliminate duplication; rewrote `test_legacy_semantic_neighborhood_still_works_for_simple_ids` as a fully isolated `TestClient` session instead of mutating the shared module-scoped `client` fixture.
- **Fix: Explorer Distance Intelligence visible rendering** (PR #513 by @ZohaibHassan16, review fixes by @KaifAhmad1):
- Distance Intelligence now renders as a first-class visual state through the Sigma reducer/theme pipeline instead of mutating raw graph attributes directly.
- Ego mode fades and scales nodes by structural distance from the selected anchor; nodes outside `maxHops` are dimmed and label-suppressed.
- Heatmap mode renders a sampled local lens capped per ring (1-hop: ≤120, 2-hop: ≤650, 3-hop: ≤900 nodes shown); true counts remain visible in the status strip. Saturation detection reduces alpha for dense outer rings automatically.
- Structural mode highlights distance-aware context edges (colored by hop band) without breaking existing edge LOD.
- Semantic mode surfaces loading, unavailable, and error states visibly; edges colored by cosine similarity score.
- Trace Path inspector shows a distance band chip, hop count, and optional metric cards (confidence decay, semantic similarity, path coherence, bottleneck node) when path data is available.
- Added `GraphDistanceVisualState`, `GraphDistanceBucketCounts`, and `GraphHeatmapRenderSnapshot` types in `types.ts`; distance state flows through `GraphCanvas``buildReducerSceneState` → Sigma node/edge reducers.
- Added `buildStructuralDistanceSnapshot` (bounded BFS), `summarizeDistanceBuckets`, `buildHeatmapRenderSnapshot` (ring-capped deterministic sampling via `hashString` tiebreaker), `resolveDistanceNodeStyle`, and `resolveDistanceEdgeStyle` in `graphSceneState.ts`.
- Distance Intelligence status strip shows active mode, anchor label, per-ring node counts, sampled status, and a color legend.
- **Review blockers fixed** (follow-up by @KaifAhmad1 and @ZohaibHassan16): removed dead `if (anchorNodeId)` conditional in `buildHeatmapRenderSnapshot` (anchor always truthy past early-return guard); replaced O(n) `.includes()` call in the Sigma reducer hot path with a `WeakMap`-cached `Set.has()` lookup; renamed `GraphDistanceBucketCounts.threeHop → threeHopPlus` so the field accurately reflects ≥ 3 hops and updated status strip labels to "3+ hop"; restored `hasMetrics` guard in `PathDistanceIntelPanel` to suppress the empty metric grid `<div>` when a path result carries no optional metric fields.
- **Feature: Graph Explorer visual refresh** (PR #503 by @ZohaibHassan16, conflict resolution by @KaifAhmad1):
- Extracted all hardcoded `rgba(...)` color literals into a structured `ui.*` design-token namespace in `graphTheme.ts` — covering `ui.text`, `ui.surface`, `ui.scene`, `ui.control`, `ui.timeline`, and `ui.interaction`. Future theming is now a one-file change.
- Added `GraphEntityShapeVariant` type and per-shape config (`fillAlpha`, `shellAlpha`, `coreScale`, `borderBoost`, `minSize`) for biomolecule, condition, compound, process, community, and entity node kinds. Shell and fill colors now derive from per-entity-shape config rather than uniform overrides.
- Decomposed the monolithic `coreToolbarGroups` useMemo into focused per-cluster memos (`viewModeItems`, `cameraToolbarItems`, `layoutToolbarItems`, `localToolbarItems`, `analysisToolbarItems`, `utilityToolbarItems`) each with minimal deps arrays. Distance Intelligence controls (ego mode, heatmap, structural/semantic overlay) ported into a new `distanceToolbarItems` cluster, gated on node selection.
- Replaced the raw `<input>` search bar and inline `<button>` loop with typed sub-components: `SearchCommandBar`, `SegmentedModeControl`, `ToolbarCluster`, `ToolbarButton`, and `EntityVisualKey`. All carry `aria-label`, `role`, and `disabled` attributes.
- Added `GraphFullEdgeClass` union (`hidden | backbone | bridge | local-context | selected | path | muted`), `classifyFullGraphEdge`, and `resolveEdgeVisibilityPolicy` for deterministic per-mode LOD edge classification. Full-graph mode visibility and context caps are now declared as data (`edges.visibility`, `edges.contextCaps`) per view mode × zoom tier.
- Added `GraphRuntimeDiagnosticsSnapshot` type; `onDiagnosticsChange` callback now emits `{ effectAvailability, edgeClasses, structureLayer }` instead of the internal `effectAvailability` sub-object.
- Edge visual state weights (size multipliers, min sizes) tuned for quieter large-graph rendering: default edges dropped from `0.96×` to `0.48×`; muted edges from `0.6×` to `0.24×`; path/selected edges raised slightly to maintain hierarchy contrast.
- Scene grid updated to a two-frequency pattern (minor 48 px, major 240 px) with tokens sourced from `GRAPH_THEME.ui.scene`.
- `focusNode` early-return now clears `selectedNodeId`, `selectedEdgeId`, `pathResult`, `searchResults`, and `searchError` when called with an empty string.
- Added 15 new display-state and edge-classification tests in `explorer/tests/graphSceneState.display.test.ts`.
- **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):
+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
+900 -313
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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",
@@ -22,6 +23,7 @@
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"lucide-react": "^1.7.0",
"playwright": "^1.59.1",
"react": "^19.2.4",
"react-arborist": "^3.4.3",
"react-dom": "^19.2.4",
@@ -43,6 +45,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^5.4.0"
+967 -6
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
/* ── Semantica Explorer — Global CSS Reset ── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;600;700;800&display=swap');
*, *::before, *::after {
margin: 0;
@@ -12,7 +12,7 @@ html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: 'IBM Plex Sans', 'Space Grotesk', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #0d1117;
+12
View File
@@ -3,6 +3,7 @@ import type {
GraphArrowVisibilityPolicy,
GraphBadgeKind,
GraphEdgeVariant,
GraphEntityShapeVariant,
GraphLabelVisibilityPolicy,
GraphNodeShapeVariant,
} from "../workspaces/GraphWorkspace/graphTheme";
@@ -36,12 +37,17 @@ export interface NodeAttributes {
borderSize?: number;
nodeVariant?: GraphNodeShapeVariant;
nodeShapeVariant?: GraphNodeShapeVariant;
entityShape?: GraphEntityShapeVariant;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
ringColor?: string;
haloColor?: string;
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
highlighted?: boolean;
communityId?: string;
isCommunityGroup?: boolean;
memberCount?: number;
anchorNodeId?: string | null;
nodeType: string;
content: string;
@@ -74,6 +80,12 @@ export interface EdgeAttributes {
parallelIndex?: number;
parallelCount?: number;
familySize?: number;
rawEdgeIds?: string[];
isAggregated?: boolean;
aggregateCount?: number;
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
edgeType: string;
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import type { CSSProperties } from "react";
import { Loader2 } from "lucide-react";
import { graph } from "../../store/graphStore";
import { GRAPH_THEME } from "./graphTheme";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type { GraphSelectedNodeKind } from "./types";
export type LinkPrediction = {
target: string;
@@ -14,10 +15,23 @@ 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;
@@ -39,6 +53,129 @@ function sourceAttribution(properties: Record<string, unknown>) {
.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 bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
const hasMetrics =
result.confidence_decay != null ||
result.semantic_similarity != null ||
result.path_coherence_score != null ||
result.bottleneck_node != null;
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 */}
{hasMetrics && <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 {
@@ -76,11 +213,13 @@ function PathFlowViz({
path,
edgeIds,
totalWeight,
bottleneckNodeId,
onFocusNode,
}: {
path: string[];
edgeIds?: string[];
totalWeight: number;
bottleneckNodeId?: string | null;
onFocusNode?: (nodeId: string) => void;
}) {
if (path.length === 0) {
@@ -103,10 +242,13 @@ function PathFlowViz({
{/* Node chip */}
<button
onClick={() => onFocusNode?.(nodeId)}
title={`Focus: ${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>
@@ -146,6 +288,10 @@ function PathFlowViz({
export function GraphInspectorPanel({
nodeId,
inspectableNodeId,
selectedNodeKind = "none",
canActivateFocused = false,
focusedUnavailableReason = null,
predictions,
predictionType,
onPredictionTypeChange,
@@ -161,17 +307,48 @@ export function GraphInspectorPanel({
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 style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 14, height: 14, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft }} />
</div>
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
<p style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 14, margin: 0, lineHeight: 1.6 }}>
Search for a node or click one in the canvas to inspect its properties.
</p>
</div>
);
}
const attributes = graph.getNodeAttributes(nodeId) as {
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
const directlyInspectable = graph.hasNode(nodeId);
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
if (!effectiveNodeId) {
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: GRAPH_THEME.ui.timeline.playhead, boxShadow: "0 0 10px rgba(98, 226, 205, 0.34)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 12, fontWeight: 700 }}>Selection</span>
</div>
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{nodeId}
</h3>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
</div>
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: GRAPH_THEME.ui.text.body, 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;
@@ -191,15 +368,29 @@ export function GraphInspectorPanel({
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={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{String(attributes?.label ?? nodeId)}
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, 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" }}>{nodeId}</div>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
{groupedDisplaySelection ? nodeId : effectiveNodeId}
</div>
{groupedDisplaySelection ? (
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: GRAPH_THEME.ui.text.body, 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>
@@ -211,7 +402,7 @@ export function GraphInspectorPanel({
{/* 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" }}>
<div style={{ padding: "10px 12px", background: "rgba(233, 196, 122, 0.075)", border: "1px solid rgba(233, 196, 122, 0.22)", borderRadius: 8, fontSize: 12, color: GRAPH_THEME.palette.accent.selected, fontFamily: "monospace" }}>
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
</div>
@@ -224,7 +415,7 @@ export function GraphInspectorPanel({
<button
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
onClick={onRunPredictions}
disabled={isRunningPredictions}
disabled={isRunningPredictions || !actionNodeId}
>
{isRunningPredictions ? (
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
@@ -232,10 +423,10 @@ export function GraphInspectorPanel({
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
</button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
Provenance JSON
</button>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
Provenance MD
</button>
</div>
@@ -257,15 +448,19 @@ export function GraphInspectorPanel({
placeholder="Target node ID"
style={inputStyle}
/>
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
{pathResult?.path?.length ? (
<PathFlowViz
path={pathResult.path}
edgeIds={pathResult.edge_ids}
totalWeight={pathResult.total_weight}
onFocusNode={onFocusNode}
/>
<>
<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.
@@ -287,8 +482,8 @@ export function GraphInspectorPanel({
>
<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 style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600 }}>{prediction.label || prediction.target}</div>
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12 }}>{prediction.type}</div>
</div>
<div style={{ flexShrink: 0 }}>
<div style={{
@@ -296,9 +491,9 @@ export function GraphInspectorPanel({
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: "rgba(88,166,255,0.12)",
border: "1px solid rgba(88,166,255,0.22)",
color: "#58a6ff",
background: GRAPH_THEME.ui.timeline.playheadSoft,
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
color: GRAPH_THEME.ui.timeline.playhead,
}}>
{(prediction.score * 100).toFixed(1)}%
</div>
@@ -326,8 +521,8 @@ export function GraphInspectorPanel({
<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" }}>
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
@@ -347,8 +542,8 @@ export function GraphInspectorPanel({
<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" }}>
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
@@ -367,19 +562,27 @@ export function GraphInspectorPanel({
const inputStyle: CSSProperties = {
width: "100%",
background: "rgba(4, 10, 18, 0.5)",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
color: "#edf5ff",
background: GRAPH_THEME.ui.control.inputBg,
border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`,
color: GRAPH_THEME.ui.text.strong,
borderRadius: 12,
padding: "11px 13px",
fontSize: 13,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
};
const groupedSelectionNoticeStyle: CSSProperties = {
marginTop: 12,
padding: "10px 12px",
background: "rgba(98, 226, 205, 0.07)",
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
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}`,
background: GRAPH_THEME.ui.control.primaryBg,
color: GRAPH_THEME.ui.control.primaryText,
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
borderRadius: 12,
padding: "9px 12px",
cursor: "pointer",
@@ -388,47 +591,47 @@ const actionButtonStyle: CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
};
const secondaryActionButtonStyle: CSSProperties = {
...actionButtonStyle,
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
color: "#c6d4e3",
background: GRAPH_THEME.ui.control.defaultBg,
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
color: GRAPH_THEME.ui.control.defaultText,
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)",
background: "rgba(255, 255, 255, 0.035)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 10,
cursor: "pointer",
width: "100%",
};
const propertyCardStyle: CSSProperties = {
background: "rgba(0, 0, 0, 0.2)",
background: "rgba(255, 255, 255, 0.028)",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255, 255, 255, 0.05)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const emptyTextStyle: CSSProperties = {
color: "#8b949e",
color: GRAPH_THEME.ui.text.muted,
fontSize: 12,
lineHeight: 1.5,
};
const subtleChipStyle: CSSProperties = {
background: "rgba(255, 255, 255, 0.04)",
color: "#9fb6d2",
color: GRAPH_THEME.ui.text.body,
padding: "4px 8px",
borderRadius: 999,
fontSize: 11,
border: "1px solid rgba(255, 255, 255, 0.06)",
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
};
const sectionStyle: CSSProperties = {
@@ -436,13 +639,13 @@ const sectionStyle: CSSProperties = {
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)",
background: GRAPH_THEME.ui.surface.cardSubtle,
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
borderRadius: 14,
};
const sectionTitleStyle: CSSProperties = {
color: "#8b949e",
color: GRAPH_THEME.ui.text.muted,
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
@@ -463,9 +666,9 @@ const pathNodeChipStyle: CSSProperties = {
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",
background: "rgba(98, 226, 205, 0.08)",
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
color: GRAPH_THEME.ui.text.strong,
fontSize: 12,
fontWeight: 600,
maxWidth: 160,
@@ -478,8 +681,8 @@ const pathNodeIndexStyle: CSSProperties = {
width: 16,
height: 16,
borderRadius: "50%",
background: "rgba(88,166,255,0.22)",
color: "#79c0ff",
background: GRAPH_THEME.ui.timeline.playheadSoft,
color: GRAPH_THEME.ui.timeline.playhead,
fontSize: 9,
fontWeight: 800,
flexShrink: 0,
@@ -495,7 +698,7 @@ const pathEdgeConnectorStyle: CSSProperties = {
const pathEdgeLabelStyle: CSSProperties = {
fontSize: 9,
fontWeight: 700,
color: "#6a7f97",
color: GRAPH_THEME.ui.text.subtle,
letterSpacing: "0.04em",
textTransform: "uppercase",
maxWidth: 70,
@@ -503,3 +706,41 @@ const pathEdgeLabelStyle: CSSProperties = {
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}
File diff suppressed because it is too large Load Diff
@@ -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]);
@@ -500,8 +510,13 @@ export function GraphWorkspaceShell() {
if (!selectedNodeId || !pathTargetId.trim()) return;
try {
const pathParams = new URLSearchParams({
source: selectedNodeId,
target: pathTargetId.trim(),
algorithm: "dijkstra",
});
const response = await fetch(
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
`/api/graph/path?${pathParams.toString()}`,
);
if (!response.ok) {
throw new Error(`Path lookup failed with status ${response.status}`);
@@ -553,6 +568,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 +657,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) => {
@@ -3,6 +3,7 @@ import { DataSet } from "vis-data";
import { Timeline } from "vis-timeline";
import type { TimelineOptions } from "vis-timeline";
import "vis-timeline/styles/vis-timeline-graph2d.css";
import { GRAPH_THEME } from "./graphTheme";
export interface TimelinePanelProps {
onTimeChange: (time: Date) => void;
@@ -19,35 +20,35 @@ const PLAY_STEP_MONTHS = 6;
const VIS_OVERRIDE_CSS = `
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
.sem-timeline-wrap .vis-panel { border-color: ${GRAPH_THEME.ui.timeline.border} !important; }
.sem-timeline-wrap .vis-time-axis .vis-text {
color: #8b949e !important;
color: ${GRAPH_THEME.ui.timeline.text} !important;
font-size: 11px !important;
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
padding-top: 3px !important;
}
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
color: #c9d1d9 !important;
color: ${GRAPH_THEME.ui.timeline.textStrong} !important;
font-weight: 700 !important;
font-size: 12px !important;
}
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: ${GRAPH_THEME.ui.timeline.gridMinor} !important; }
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: ${GRAPH_THEME.ui.timeline.gridMajor} !important; }
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
background: rgba(88, 166, 255, 0.15) !important;
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !important;
width: 2px !important;
cursor: ew-resize !important;
z-index: 5 !important;
}
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
background: #58a6ff !important;
color: #0d1117 !important;
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
color: ${GRAPH_THEME.ui.text.inverse} !important;
font-size: 10px !important;
font-weight: 700 !important;
border-radius: 3px !important;
padding: 1px 5px !important;
white-space: nowrap !important;
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
}
.sem-timeline-wrap .vis-current-time { display: none !important; }
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
@@ -166,14 +167,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
useEffect(() => () => stopPlay(), [stopPlay]);
return (
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<style>{VIS_OVERRIDE_CSS}</style>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, minWidth: 80, flexShrink: 0 }}>
<button
id="temporal-play-btn"
onClick={togglePlay}
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? GRAPH_THEME.ui.control.activeBorder : GRAPH_THEME.ui.control.defaultBorder}`, background: isPlaying ? GRAPH_THEME.ui.timeline.playheadSoft : GRAPH_THEME.ui.control.defaultBg, color: GRAPH_THEME.ui.timeline.playhead, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(98, 226, 205, 0.32)" : "none" }}
>
{isPlaying ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
@@ -181,12 +182,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
)}
</button>
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
<span style={{ fontSize: 10, color: isPlaying ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
{displayDate}
</span>
</div>
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
</div>
@@ -7,11 +7,19 @@ export const clickSelectionBehavior: GraphBehavior = {
onNodeClick: (context, nodeId) => {
context.setHoveredNodeId(nodeId);
context.onEdgeSelectionChange("");
context.onNodeSelectionChange(nodeId);
if (context.getInteractionState().selectedNodeId === nodeId) {
context.onNodeSelectionChange("");
} else {
context.onNodeSelectionChange(nodeId);
}
},
onEdgeClick: (context, edgeId) => {
context.setHoveredNodeId(null);
context.onEdgeSelectionChange(edgeId);
if (context.getInteractionState().selectedEdgeId === edgeId) {
context.onEdgeSelectionChange("");
} else {
context.onEdgeSelectionChange(edgeId);
}
},
onStageClick: (context) => {
context.setHoveredNodeId(null);
@@ -5,11 +5,21 @@ export const focusCameraBehavior: GraphBehavior = {
attach: () => {},
detach: () => {},
performAction: (context, action) => {
if (action.type !== "focusNode") {
return false;
if (action.type === "focusNode") {
context.focusNodeInView(action.nodeId);
return true;
}
context.focusNodeInView(action.nodeId);
return true;
if (action.type === "centerSelection") {
context.centerSelectionInView(action.nodeId);
return true;
}
if (action.type === "centerGroupedSelection") {
context.centerGroupedSelectionInView(action.nodeId);
return true;
}
return false;
},
};
@@ -1,13 +1,37 @@
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: () => {
detach: (context) => {
cancelSweep();
lastPathSignature = "";
context.sigma.refresh();
},
onStateChange: (context, interactionState) => {
const nextPathSignature = interactionState.activePath.join("::");
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
}
lastPathSignature = nextPathSignature;
cancelSweep();
context.sigma.refresh();
// Animate intermediate nodes lighting up sequentially
if (interactionState.activePath.length > 2) {
scheduleSweep(context.sigma, 0, sweepGeneration);
}
},
};
}
@@ -1,23 +1,36 @@
import type { GraphBehavior } from "./types";
export function createSearchFocusBehavior(): GraphBehavior {
let lastFocusedNodeId = "";
let lastSelectedNodeId = "";
let lastViewMode = "";
return {
id: "search-focus",
attach: () => {},
detach: () => {
lastFocusedNodeId = "";
lastSelectedNodeId = "";
lastViewMode = "";
},
onStateChange: (context, interactionState) => {
const nextFocusedNodeId = interactionState.focusedNodeId;
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
lastFocusedNodeId = nextFocusedNodeId;
const nextSelectedNodeId = interactionState.selectedNodeId;
const nextViewMode = interactionState.viewMode;
if (nextViewMode !== lastViewMode) {
lastViewMode = nextViewMode;
lastSelectedNodeId = nextSelectedNodeId;
return;
}
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
lastSelectedNodeId = nextSelectedNodeId;
lastViewMode = nextViewMode;
return;
}
lastFocusedNodeId = nextFocusedNodeId;
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
lastSelectedNodeId = nextSelectedNodeId;
lastViewMode = nextViewMode;
context.dispatchAction({
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
nodeId: nextSelectedNodeId,
});
},
};
}
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
export type GraphBehaviorActionRequest =
| { type: "fitView" }
| { type: "focusNode"; nodeId: string };
| { type: "focusNode"; nodeId: string }
| { type: "centerSelection"; nodeId: string }
| { type: "centerGroupedSelection"; nodeId: string };
export interface GraphBehaviorContext {
sigma: Sigma;
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
onNodeSelectionChange: (nodeId: string) => void;
onEdgeSelectionChange: (edgeId: string) => void;
focusNodeInView: (nodeId: string) => void;
centerSelectionInView: (nodeId: string) => void;
centerGroupedSelectionInView: (nodeId: string) => void;
fitCurrentView: () => void;
dispatchAction: (action: GraphBehaviorActionRequest) => void;
}
@@ -1,7 +1,8 @@
import type { GraphBehavior } from "./types";
import type { GraphViewMode } from "../types";
export function createViewModeSwitchBehavior(): GraphBehavior {
let lastViewMode: "focused" | "full" | null = null;
let lastViewMode: GraphViewMode | null = null;
return {
id: "view-mode-switch",
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
}
lastViewMode = interactionState.viewMode;
const nextFocusedNodeId = interactionState.focusedNodeId;
if (interactionState.focusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
return;
}
@@ -44,8 +44,18 @@ const MAX_REGION_SUMMARIES = 6;
const MAX_CENTRALITY_SUMMARIES = 6;
const CENTRALITY_ITERATIONS = 24;
const MAX_BACKBONE_ANCHORS = 4;
const MAX_BACKBONE_CENTRAL_LINKS = 2;
const MAX_BACKBONE_BRIDGES = 4;
const MAX_BACKBONE_CENTRAL_LINKS = 36;
const MAX_BACKBONE_BRIDGES = 80;
const MAX_BACKBONE_TOTAL_EDGES = 128;
const MAX_BACKBONE_EDGES_PER_NODE = 5;
const MAX_BACKBONE_PARALLEL_PAIR_EDGES = 2;
type BackboneCandidate = {
edgeId: string;
source: string;
target: string;
score: number;
};
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
@@ -364,6 +374,61 @@ function scoreBackboneEdge(
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
}
function upsertBackboneCandidate(
candidates: Map<string, BackboneCandidate>,
key: string,
candidate: BackboneCandidate,
) {
const current = candidates.get(key);
if (
!current
|| candidate.score > current.score
|| (candidate.score === current.score && candidate.edgeId.localeCompare(current.edgeId) < 0)
) {
candidates.set(key, candidate);
}
}
function addRankedBackboneCandidates(
selected: BackboneCandidate[],
selectedEdgeIds: Set<string>,
nodeUseCounts: Map<string, number>,
pairUseCounts: Map<string, number>,
candidates: Iterable<BackboneCandidate>,
maxToAdd: number,
) {
const ranked = [...candidates].sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
});
for (const candidate of ranked) {
if (selected.length >= MAX_BACKBONE_TOTAL_EDGES || maxToAdd <= 0 || selectedEdgeIds.has(candidate.edgeId)) {
continue;
}
const pairKey = [candidate.source, candidate.target].sort().join("::");
if ((nodeUseCounts.get(candidate.source) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
continue;
}
if ((nodeUseCounts.get(candidate.target) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
continue;
}
if ((pairUseCounts.get(pairKey) ?? 0) >= MAX_BACKBONE_PARALLEL_PAIR_EDGES) {
continue;
}
selected.push(candidate);
selectedEdgeIds.add(candidate.edgeId);
nodeUseCounts.set(candidate.source, (nodeUseCounts.get(candidate.source) ?? 0) + 1);
nodeUseCounts.set(candidate.target, (nodeUseCounts.get(candidate.target) ?? 0) + 1);
pairUseCounts.set(pairKey, (pairUseCounts.get(pairKey) ?? 0) + 1);
maxToAdd -= 1;
}
}
function buildOverviewBackboneSnapshot(
graphRef: GraphRef,
visibleNodeIds: Set<string>,
@@ -379,7 +444,10 @@ function buildOverviewBackboneSnapshot(
};
}
const selected: BackboneCandidate[] = [];
const selectedEdgeIds = new Set<string>();
const nodeUseCounts = new Map<string, number>();
const pairUseCounts = new Map<string, number>();
const regionByNode = new Map<string, string>();
visibleNodeIds.forEach((nodeId) => {
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
@@ -397,7 +465,7 @@ function buildOverviewBackboneSnapshot(
.map((summary) => summary.id)
.filter((nodeId) => visibleNodeIds.has(nodeId));
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
const coreLinkCandidates = new Map<string, BackboneCandidate>();
anchorIds.forEach((anchorId) => {
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
.filter((entry) => {
@@ -415,60 +483,88 @@ function buildOverviewBackboneSnapshot(
const targetRegion = regionByNode.get(entry.target);
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
const current = coreLinkCandidates.get(pairKey);
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
}
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
edgeId: entry.edgeId,
source: entry.source,
target: entry.target,
score,
});
});
});
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
const bridgeCandidates = new Map<string, BackboneCandidate>();
const structuralCandidates = new Map<string, BackboneCandidate>();
graphRef.forEachEdge((edgeId, attrs, source, target) => {
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
return;
}
const sourceRegion = regionByNode.get(source);
const targetRegion = regionByNode.get(target);
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
return;
const edgeKey = String(edgeId);
const sourceId = String(source);
const targetId = String(target);
const sourceRegion = regionByNode.get(sourceId);
const targetRegion = regionByNode.get(targetId);
const sourceCommunity = base.communitiesByNode.get(sourceId);
const targetCommunity = base.communitiesByNode.get(targetId);
const crossesSemanticRegion = Boolean(sourceRegion && targetRegion && sourceRegion !== targetRegion);
const crossesCommunity = sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity !== targetCommunity;
const sourceCentrality = base.centralityByNode.get(sourceId)?.score ?? 0;
const targetCentrality = base.centralityByNode.get(targetId)?.score ?? 0;
const baseScore = scoreBackboneEdge(attrs as EdgeAttributes, sourceId, targetId, base);
const semanticBoost = crossesSemanticRegion ? 0.5 : 0;
const communityBoost = crossesCommunity ? 0.36 : 0;
const topRegionBoost = sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)) ? 0.32 : 0;
const centralityBalance = Math.min(sourceCentrality, targetCentrality) * 1.2;
const score = baseScore + semanticBoost + communityBoost + topRegionBoost + centralityBalance;
const candidate = {
edgeId: edgeKey,
source: sourceId,
target: targetId,
score,
};
if (crossesSemanticRegion || crossesCommunity) {
const bridgeKey = [
sourceRegion ?? `community:${sourceCommunity ?? sourceId}`,
targetRegion ?? `community:${targetCommunity ?? targetId}`,
Math.min(sourceCentrality, targetCentrality).toFixed(4),
].sort().join("::");
upsertBackboneCandidate(bridgeCandidates, bridgeKey, candidate);
}
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
return;
}
const pairKey = [sourceRegion, targetRegion].sort().join("::");
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
const current = bridgeByPair.get(pairKey);
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
}
const pairKey = [sourceId, targetId].sort().join("::");
upsertBackboneCandidate(structuralCandidates, pairKey, candidate);
});
[...bridgeByPair.values()]
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
})
.slice(0, MAX_BACKBONE_BRIDGES)
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
bridgeCandidates.values(),
MAX_BACKBONE_BRIDGES,
);
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
coreLinkCandidates.values(),
MAX_BACKBONE_CENTRAL_LINKS,
);
addRankedBackboneCandidates(
selected,
selectedEdgeIds,
nodeUseCounts,
pairUseCounts,
structuralCandidates.values(),
MAX_BACKBONE_TOTAL_EDGES - selected.length,
);
[...coreLinkCandidates.values()]
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
})
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
const edgeIds = [...selectedEdgeIds]
.filter((edgeId) => graphRef.hasEdge(edgeId))
.sort((left, right) => left.localeCompare(right));
const edgeIds = selected
.map((entry) => entry.edgeId)
.filter((edgeId) => graphRef.hasEdge(edgeId));
return {
ready: edgeIds.length > 0,
@@ -0,0 +1,34 @@
import type { GraphEntityShapeVariant } from "./graphTheme";
export const ENTITY_SHAPE_ALIASES: Array<[GraphEntityShapeVariant, RegExp]> = [
["biomolecule", /\b(gene|protein|enzyme|receptor|target|transcript|rna|dna|mirna|biomolecule|peptide)\b/i],
["condition", /\b(disease|condition|phenotype|symptom|disorder|syndrome|diagnosis|pathology|trait)\b/i],
["compound", /\b(drug|chemical|compound|metabolite|molecule|small[_\s-]?molecule|ligand|therapeutic|medication|substance)\b/i],
["process", /\b(pathway|process|mechanism|function|ontology|biological[_\s-]?process|cellular[_\s-]?process|program|module)\b/i],
];
export function classifyEntityShape(
nodeType?: string,
semanticGroup?: string,
content?: string,
properties?: Record<string, unknown>,
): GraphEntityShapeVariant {
const values = [
nodeType,
semanticGroup,
content,
String(properties?.type ?? ""),
String(properties?.category ?? ""),
String(properties?.label ?? ""),
]
.filter((value) => typeof value === "string" && value.trim().length > 0)
.join(" ");
for (const [shape, pattern] of ENTITY_SHAPE_ALIASES) {
if (pattern.test(values)) {
return shape;
}
}
return "entity";
}
@@ -266,8 +266,8 @@ function renderDensityField(
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
);
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
gradient.addColorStop(0, "rgba(255,255,255,0.05)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
import type Graph from "graphology";
import type Sigma from "sigma";
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type {
GraphFullEdgeClass,
GraphFullEdgeClassDiagnostics,
GraphInteractionState,
GraphStructureLayerDiagnostics,
GraphStructureLayerDisabledReason,
GraphViewMode,
} from "./types";
type GraphRef = Graph;
type StructureLayerMode = typeof GRAPH_THEME.edges.fullGraphStructureLayer.mode;
export type GraphStructureCurve = {
edgeId: string;
sourceId: string;
targetId: string;
source: { x: number; y: number };
target: { x: number; y: number };
edgeClass: Extract<GraphFullEdgeClass, "backbone" | "bridge">;
priority: number;
curvature: number;
};
export type GraphStructureCurveCache = {
cacheKey: string;
curves: GraphStructureCurve[];
bridgeCurveCount: number;
backboneCurveCount: number;
};
export type GraphStructureLayerGateInput = {
mode: StructureLayerMode;
viewMode: GraphViewMode;
isLayoutRunning: boolean;
edgeDiagnostics?: GraphFullEdgeClassDiagnostics;
minimumLiteralEdges: number;
};
export type GraphStructureLayerGate = {
enabled: boolean;
disabledReason: GraphStructureLayerDisabledReason | null;
};
export function evaluateGraphStructureLayerGate({
mode,
viewMode,
isLayoutRunning,
edgeDiagnostics,
minimumLiteralEdges,
}: GraphStructureLayerGateInput): GraphStructureLayerGate {
if (mode === "off") {
return { enabled: false, disabledReason: "disabled" };
}
if (viewMode !== "full") {
return { enabled: false, disabledReason: "non-full-mode" };
}
if (isLayoutRunning) {
return { enabled: false, disabledReason: "layout-running" };
}
if (mode === "auto") {
const literalEdges = (edgeDiagnostics?.counts.backbone ?? 0) + (edgeDiagnostics?.counts.bridge ?? 0);
if (literalEdges >= minimumLiteralEdges) {
return { enabled: false, disabledReason: "enough-literal-edges" };
}
}
return { enabled: true, disabledReason: null };
}
function isFinitePoint(attrs: NodeAttributes) {
return Number.isFinite(Number(attrs.x)) && Number.isFinite(Number(attrs.y));
}
function getEdgePriority(attrs: EdgeAttributes) {
return Math.max(0, Math.min(1, Number(attrs.visualPriority ?? attrs.weight ?? 0)));
}
function getCurveSortRank(edgeClass: GraphFullEdgeClass, priority: number) {
return (edgeClass === "bridge" ? 2 : 1) + priority;
}
function getDeterministicCurveSign(sourceId: string, targetId: string, edgeId: string) {
const seed = `${sourceId}|${targetId}|${edgeId}`;
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
}
return hash % 2 === 0 ? 1 : -1;
}
export function createGraphStructureCacheKey({
graphVersion,
zoomTier,
layoutSettledEpoch,
overviewBackboneEdgeIds,
}: {
graphVersion: number;
zoomTier: GraphInteractionState["zoomTier"];
layoutSettledEpoch: number;
overviewBackboneEdgeIds: Set<string>;
}) {
return [
graphVersion,
zoomTier,
layoutSettledEpoch,
Array.from(overviewBackboneEdgeIds).sort().join(","),
].join("|");
}
export function buildGraphStructureCurveCache({
graphRef,
cacheKey,
classifyEdge,
maxCurves,
curveStrength,
}: {
graphRef: GraphRef;
cacheKey: string;
classifyEdge: (edgeId: string) => GraphFullEdgeClass;
maxCurves: number;
curveStrength: number;
}): GraphStructureCurveCache {
const candidates: Array<GraphStructureCurve & { rank: number }> = [];
graphRef.forEachEdge((edgeId, attrs, source, target) => {
const stableEdgeId = String(edgeId);
const edgeClass = classifyEdge(stableEdgeId);
if (edgeClass !== "bridge" && edgeClass !== "backbone") {
return;
}
const sourceId = String(source);
const targetId = String(target);
if (!graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
return;
}
const sourceAttrs = graphRef.getNodeAttributes(sourceId) as NodeAttributes;
const targetAttrs = graphRef.getNodeAttributes(targetId) as NodeAttributes;
if (!isFinitePoint(sourceAttrs) || !isFinitePoint(targetAttrs)) {
return;
}
const priority = getEdgePriority(attrs as EdgeAttributes);
candidates.push({
edgeId: stableEdgeId,
sourceId,
targetId,
source: { x: Number(sourceAttrs.x), y: Number(sourceAttrs.y) },
target: { x: Number(targetAttrs.x), y: Number(targetAttrs.y) },
edgeClass,
priority,
curvature: getDeterministicCurveSign(sourceId, targetId, stableEdgeId) * curveStrength,
rank: getCurveSortRank(edgeClass, priority),
});
});
candidates.sort((left, right) => {
if (right.rank !== left.rank) {
return right.rank - left.rank;
}
return left.edgeId.localeCompare(right.edgeId);
});
const curves = candidates.slice(0, maxCurves).map(({ rank: _rank, ...curve }) => curve);
return {
cacheKey,
curves,
bridgeCurveCount: curves.filter((curve) => curve.edgeClass === "bridge").length,
backboneCurveCount: curves.filter((curve) => curve.edgeClass === "backbone").length,
};
}
export function getGraphStructureLayerDiagnostics({
gate,
cache,
minimumCurves,
canvasAvailable,
lastDrawAt,
}: {
gate: GraphStructureLayerGate;
cache: GraphStructureCurveCache | null;
minimumCurves: number;
canvasAvailable: boolean;
lastDrawAt: number | null;
}): GraphStructureLayerDiagnostics {
if (!gate.enabled) {
return {
enabled: false,
disabledReason: gate.disabledReason,
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (!canvasAvailable) {
return {
enabled: false,
disabledReason: "invalid-layer",
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (!cache || cache.curves.length === 0) {
return {
enabled: false,
disabledReason: "no-eligible-edges",
curveCount: 0,
bridgeCurveCount: 0,
backboneCurveCount: 0,
cacheKey: cache?.cacheKey ?? "",
lastDrawAt,
};
}
if (cache.curves.length < minimumCurves) {
return {
enabled: false,
disabledReason: "cache-empty",
curveCount: cache.curves.length,
bridgeCurveCount: cache.bridgeCurveCount,
backboneCurveCount: cache.backboneCurveCount,
cacheKey: cache.cacheKey,
lastDrawAt,
};
}
return {
enabled: true,
disabledReason: null,
curveCount: cache.curves.length,
bridgeCurveCount: cache.bridgeCurveCount,
backboneCurveCount: cache.backboneCurveCount,
cacheKey: cache.cacheKey,
lastDrawAt,
};
}
export function clearGraphStructureLayer(canvas: HTMLCanvasElement | null) {
if (!canvas) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
}
export function drawGraphStructureLayer({
sigma,
canvas,
cache,
}: {
sigma: Sigma;
canvas: HTMLCanvasElement;
cache: GraphStructureCurveCache;
}) {
const context = canvas.getContext("2d");
if (!context) {
return false;
}
const { width, height } = sigma.getDimensions();
const pixelRatio = window.devicePixelRatio || 1;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
context.lineCap = "round";
context.lineJoin = "round";
let drawn = 0;
for (const curve of cache.curves) {
const sourceData = sigma.getNodeDisplayData(curve.sourceId);
const targetData = sigma.getNodeDisplayData(curve.targetId);
if (!sourceData || !targetData || sourceData.hidden || targetData.hidden) {
continue;
}
const sourcePoint = sigma.graphToViewport(curve.source);
const targetPoint = sigma.graphToViewport(curve.target);
if (
!Number.isFinite(sourcePoint.x)
|| !Number.isFinite(sourcePoint.y)
|| !Number.isFinite(targetPoint.x)
|| !Number.isFinite(targetPoint.y)
) {
continue;
}
const dx = targetPoint.x - sourcePoint.x;
const dy = targetPoint.y - sourcePoint.y;
const distance = Math.hypot(dx, dy);
if (distance <= 0) {
continue;
}
const nx = -dy / distance;
const ny = dx / distance;
const offset = distance * curve.curvature;
const controlX = (sourcePoint.x + targetPoint.x) / 2 + nx * offset;
const controlY = (sourcePoint.y + targetPoint.y) / 2 + ny * offset;
const layerTheme = GRAPH_THEME.edges.fullGraphStructureLayer;
context.beginPath();
context.strokeStyle = curve.edgeClass === "bridge"
? withAlpha(GRAPH_THEME.palette.muted.edgeFocus, layerTheme.bridgeAlpha)
: withAlpha(GRAPH_THEME.palette.muted.edgeStructure, layerTheme.backboneAlpha);
context.lineWidth = curve.edgeClass === "bridge"
? layerTheme.bridgeLineWidth
: layerTheme.backboneLineWidth;
context.moveTo(sourcePoint.x, sourcePoint.y);
context.quadraticCurveTo(controlX, controlY, targetPoint.x, targetPoint.y);
context.stroke();
drawn += 1;
}
return drawn > 0;
}
@@ -2,6 +2,7 @@ export type GraphZoomTier = "overview" | "structure" | "inspection";
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
export type GraphEntityShapeVariant = "entity" | "biomolecule" | "condition" | "compound" | "process" | "community";
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
@@ -9,6 +10,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: {
@@ -52,6 +54,60 @@ export interface GraphTheme {
nodeBorder: string;
};
};
ui: {
text: {
strong: string;
body: string;
muted: string;
subtle: string;
inverse: string;
};
surface: {
app: string;
stage: string;
card: string;
cardSubtle: string;
cardStrong: string;
panel: string;
panelBorder: string;
divider: string;
shadow: string;
};
scene: {
background: string;
radialGlow: string;
grid: string;
gridStrong: string;
vignette: string;
};
control: {
defaultBg: string;
defaultBorder: string;
defaultText: string;
hoverBg: string;
activeBg: string;
activeBorder: string;
activeText: string;
primaryBg: string;
primaryBorder: string;
primaryText: string;
disabledText: string;
inputBg: string;
inputBorder: string;
focusRing: string;
dangerText: string;
};
timeline: {
background: string;
border: string;
gridMinor: string;
gridMajor: string;
text: string;
textStrong: string;
playhead: string;
playheadSoft: string;
};
};
zoomTiers: Record<GraphZoomTier, {
maxRatio: number;
nodeScale: number;
@@ -133,6 +189,16 @@ export interface GraphTheme {
badgeKind?: GraphBadgeKind;
badgeVisibleFrom: GraphZoomTier;
}>;
entityShapes: Record<GraphEntityShapeVariant, {
label: string;
shapeKind: number;
aspectRatio: number;
fillAlpha: number;
shellAlpha: number;
coreScale: number;
borderBoost: number;
minSize: number;
}>;
selectedRing: {
color: string;
width: number;
@@ -170,6 +236,49 @@ export interface GraphTheme {
sizeMultiplier: number;
glowAlpha: number;
}>;
visibility: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, {
defaultPriorityThreshold: number;
backgroundSampleRate: number;
defaultAlpha: number;
mutedAlpha: number;
inactiveAlpha: number;
neighborAlpha: number;
sizeMultiplier: number;
hideMuted: boolean;
}>>;
contextCaps: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, number>>;
fullGraphStructure: {
ambientBackboneAlpha: number;
backboneAlpha: number;
bridgeAlpha: number;
bridgeCurvePriorityThreshold: number;
bridgeCurveStrength: number;
backboneMaxSize: number;
bridgeMaxSize: number;
structureEdgeAlpha: number;
inspectionEdgeAlpha: number;
};
fullGraphStructureLayer: {
mode: "off" | "auto" | "always";
minimumLiteralEdges: number;
minimumCurves: number;
maxCurves: number;
bridgeAlpha: number;
backboneAlpha: number;
bridgeLineWidth: number;
backboneLineWidth: number;
curveStrength: number;
};
};
interaction: {
localContextAlpha: number;
hoverContextAlpha: number;
selectedEdgeAlpha: number;
pathEdgeAlpha: number;
localContextMaxSize: number;
selectedEdgeMaxSize: number;
pathEdgeMaxSize: number;
pathOverlayAlpha: number;
};
overlays: {
hoverGlowAlpha: number;
@@ -190,6 +299,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;
@@ -272,9 +410,9 @@ export const GRAPH_THEME: GraphTheme = {
nodeCoreMix: 0.72,
nodeShellAlpha: 0.97,
nodeCoreAlpha: 1,
edgeBackbone: "rgba(100, 148, 210, 0.38)",
edgeStructure: "rgba(88, 140, 200, 0.28)",
edgeInspection: "rgba(110, 165, 230, 0.48)",
edgeBackbone: "rgba(84, 123, 145, 0.24)",
edgeStructure: "rgba(49, 63, 78, 0.08)",
edgeInspection: "rgba(76, 102, 128, 0.12)",
},
accent: {
selected: "#F2D288",
@@ -287,51 +425,105 @@ export const GRAPH_THEME: GraphTheme = {
muted: {
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)",
edgeOverview: "rgba(32, 45, 55, 0.035)",
edgeStructure: "rgba(42, 58, 72, 0.055)",
edgeInspection: "rgba(62, 84, 104, 0.075)",
edgeFocus: "rgba(132, 178, 202, 0.26)",
},
background: {
canvas: "#07101A",
shell: "rgba(8, 15, 26, 0.8)",
shellBorder: "rgba(118, 162, 207, 0.14)",
shellGlow: "rgba(48, 88, 140, 0.14)",
grid: "rgba(92, 126, 170, 0.034)",
vignette: "rgba(2, 5, 11, 0.84)",
nodeBorder: "#0C1522",
canvas: "#0A0D11",
shell: "rgba(17, 21, 27, 0.82)",
shellBorder: "rgba(170, 184, 205, 0.14)",
shellGlow: "rgba(0, 0, 0, 0.28)",
grid: "rgba(170, 184, 205, 0.026)",
vignette: "rgba(3, 4, 7, 0.76)",
nodeBorder: "#0B0F15",
},
},
ui: {
text: {
strong: "#F3F0E8",
body: "#D5D9DD",
muted: "#9AA3AE",
subtle: "#6F7A86",
inverse: "#0B0D10",
},
surface: {
app: "#08090B",
stage: "#0B0E12",
card: "linear-gradient(180deg, rgba(28, 31, 36, 0.88), rgba(16, 18, 23, 0.78))",
cardSubtle: "linear-gradient(180deg, rgba(23, 26, 31, 0.72), rgba(13, 15, 19, 0.64))",
cardStrong: "linear-gradient(180deg, rgba(34, 37, 43, 0.94), rgba(18, 21, 26, 0.9))",
panel: "linear-gradient(180deg, rgba(21, 24, 30, 0.92), rgba(12, 14, 18, 0.9))",
panelBorder: "rgba(211, 205, 190, 0.13)",
divider: "rgba(211, 205, 190, 0.1)",
shadow: "0 22px 60px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.045)",
},
scene: {
background: "linear-gradient(180deg, #0B0E12 0%, #07080B 100%)",
radialGlow: "radial-gradient(circle at 50% 18%, rgba(88, 224, 204, 0.07), transparent 30%), radial-gradient(circle at 78% 0%, rgba(217, 168, 92, 0.055), transparent 26%)",
grid: "rgba(210, 206, 196, 0.024)",
gridStrong: "rgba(210, 206, 196, 0.052)",
vignette: "radial-gradient(ellipse at center, transparent 42%, rgba(2, 3, 5, 0.82) 100%)",
},
control: {
defaultBg: "rgba(255, 255, 255, 0.035)",
defaultBorder: "rgba(211, 205, 190, 0.11)",
defaultText: "#D7D1C4",
hoverBg: "rgba(255, 255, 255, 0.065)",
activeBg: "linear-gradient(180deg, rgba(74, 181, 166, 0.24), rgba(38, 118, 116, 0.18))",
activeBorder: "rgba(98, 226, 205, 0.42)",
activeText: "#E8FFFA",
primaryBg: "linear-gradient(180deg, rgba(55, 145, 132, 0.42), rgba(24, 86, 88, 0.28))",
primaryBorder: "rgba(99, 228, 206, 0.34)",
primaryText: "#F2FFFB",
disabledText: "rgba(154, 163, 174, 0.42)",
inputBg: "rgba(5, 7, 10, 0.52)",
inputBorder: "rgba(211, 205, 190, 0.13)",
focusRing: "rgba(98, 226, 205, 0.16)",
dangerText: "#FF9A8D",
},
timeline: {
background: "linear-gradient(180deg, rgba(14, 18, 24, 0.86), rgba(8, 11, 15, 0.92))",
border: "rgba(170, 184, 205, 0.12)",
gridMinor: "rgba(170, 184, 205, 0.04)",
gridMajor: "rgba(170, 184, 205, 0.09)",
text: "#7A92AE",
textStrong: "#A5B7CD",
playhead: "#8FE7FF",
playheadSoft: "rgba(143, 231, 255, 0.12)",
},
},
zoomTiers: {
overview: {
maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.88,
labelThreshold: 0.92,
labelBudget: 28,
edgePriorityThreshold: 0.55,
nodeScale: 0.72,
labelThreshold: 0.998,
labelBudget: 2,
edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.62,
showBadges: false,
showCurves: false,
showCurves: true,
showContextualArrows: false,
},
structure: {
maxRatio: 1.2,
nodeScale: 1.02,
labelThreshold: 0.82,
labelBudget: 60,
edgePriorityThreshold: 0.3,
arrowPriorityThreshold: 0.65,
edgeSizeScale: 1.05,
showBadges: true,
nodeScale: 0.94,
labelThreshold: 0.95,
labelBudget: 12,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
showBadges: false,
showCurves: true,
showContextualArrows: true,
showContextualArrows: false,
},
inspection: {
maxRatio: 0.5,
nodeScale: 1.08,
labelThreshold: 0.6,
labelBudget: 120,
nodeScale: 1,
labelThreshold: 0.8,
labelBudget: 40,
edgePriorityThreshold: 0,
arrowPriorityThreshold: 0.45,
edgeSizeScale: 1.18,
@@ -341,7 +533,7 @@ export const GRAPH_THEME: GraphTheme = {
},
},
labels: {
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
forceVisibleStates: ["hovered", "selected", "path"],
policies: {
none: { minZoomTier: "inspection" },
priority: { minZoomTier: "overview" },
@@ -391,28 +583,90 @@ export const GRAPH_THEME: GraphTheme = {
},
nodes: {
backgroundScale: 0.52,
mutedAlpha: 0.08,
mutedAlpha: 0.16,
strokeHierarchy: {
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
default: { color: "base", sizeMultiplier: 0.7, minSize: 0.64, forceLabel: false, zIndex: 0, borderBoost: -0.46 },
hovered: { color: "hovered", sizeMultiplier: 1.08, minSize: 10.4, forceLabel: true, zIndex: 4, borderBoost: 0.2 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.2, forceLabel: true, zIndex: 3, borderBoost: 0.22 },
neighbor: { color: "base", sizeMultiplier: 0.76, minSize: 4, forceLabel: false, zIndex: 2, borderBoost: -0.08 },
path: { color: "path", sizeMultiplier: 0.96, minSize: 5.6, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
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" },
},
entityShapes: {
entity: {
label: "Entity",
shapeKind: 0,
aspectRatio: 1,
fillAlpha: 0.9,
shellAlpha: 0.14,
coreScale: 0,
borderBoost: 0.08,
minSize: 0,
},
biomolecule: {
label: "Biomolecule",
shapeKind: 1,
aspectRatio: 1,
fillAlpha: 0.9,
shellAlpha: 0.16,
coreScale: 0.18,
borderBoost: 0.16,
minSize: 1.2,
},
condition: {
label: "Condition",
shapeKind: 2,
aspectRatio: 1.04,
fillAlpha: 0.88,
shellAlpha: 0.15,
coreScale: 0.16,
borderBoost: 0.18,
minSize: 1.6,
},
compound: {
label: "Compound",
shapeKind: 3,
aspectRatio: 1.48,
fillAlpha: 0.88,
shellAlpha: 0.15,
coreScale: 0.14,
borderBoost: 0.14,
minSize: 1.4,
},
process: {
label: "Process",
shapeKind: 4,
aspectRatio: 1.1,
fillAlpha: 0.87,
shellAlpha: 0.14,
coreScale: 0.14,
borderBoost: 0.16,
minSize: 1.4,
},
community: {
label: "Community",
shapeKind: 5,
aspectRatio: 1,
fillAlpha: 0.68,
shellAlpha: 0.28,
coreScale: 0.78,
borderBoost: 0.34,
minSize: 2,
},
},
selectedRing: {
color: "#E7C57C",
width: 1.9,
@@ -437,14 +691,14 @@ export const GRAPH_THEME: GraphTheme = {
},
edges: {
states: {
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 },
default: { color: "structure", sizeMultiplier: 0.48, minSize: 0.2, zIndex: 0, forceArrow: false, hide: false },
backbone: { color: "backbone", sizeMultiplier: 0.62, minSize: 0.36, zIndex: 1, forceArrow: false, hide: false },
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
neighbor: { color: "focus", sizeMultiplier: 0.96, minSize: 0.86, zIndex: 1, forceArrow: false, hide: false },
path: { color: "path", sizeMultiplier: 1.82, minSize: 2.55, zIndex: 6, forceArrow: true, hide: false },
inactive: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
muted: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
},
variants: {
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
@@ -453,6 +707,155 @@ export const GRAPH_THEME: GraphTheme = {
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
},
visibility: {
full: {
overview: {
defaultPriorityThreshold: 0.96,
backgroundSampleRate: 0.035,
defaultAlpha: 0.026,
mutedAlpha: 0.012,
inactiveAlpha: 0.01,
neighborAlpha: 0.26,
sizeMultiplier: 0.5,
hideMuted: true,
},
structure: {
defaultPriorityThreshold: 0.82,
backgroundSampleRate: 0.16,
defaultAlpha: 0.04,
mutedAlpha: 0.014,
inactiveAlpha: 0.012,
neighborAlpha: 0.32,
sizeMultiplier: 0.62,
hideMuted: true,
},
inspection: {
defaultPriorityThreshold: 0.72,
backgroundSampleRate: 0.28,
defaultAlpha: 0.052,
mutedAlpha: 0.012,
inactiveAlpha: 0.01,
neighborAlpha: 0.38,
sizeMultiplier: 0.64,
hideMuted: true,
},
},
grouped: {
overview: {
defaultPriorityThreshold: 0.42,
backgroundSampleRate: 1,
defaultAlpha: 0.18,
mutedAlpha: 0.06,
inactiveAlpha: 0.04,
neighborAlpha: 0.36,
sizeMultiplier: 0.72,
hideMuted: false,
},
structure: {
defaultPriorityThreshold: 0.34,
backgroundSampleRate: 1,
defaultAlpha: 0.2,
mutedAlpha: 0.07,
inactiveAlpha: 0.05,
neighborAlpha: 0.42,
sizeMultiplier: 0.78,
hideMuted: false,
},
inspection: {
defaultPriorityThreshold: 0.28,
backgroundSampleRate: 1,
defaultAlpha: 0.22,
mutedAlpha: 0.08,
inactiveAlpha: 0.06,
neighborAlpha: 0.46,
sizeMultiplier: 0.82,
hideMuted: false,
},
},
focused: {
overview: {
defaultPriorityThreshold: 0.72,
backgroundSampleRate: 0.7,
defaultAlpha: 0.1,
mutedAlpha: 0.03,
inactiveAlpha: 0.02,
neighborAlpha: 0.06,
sizeMultiplier: 0.72,
hideMuted: true,
},
structure: {
defaultPriorityThreshold: 0.6,
backgroundSampleRate: 0.8,
defaultAlpha: 0.12,
mutedAlpha: 0.035,
inactiveAlpha: 0.025,
neighborAlpha: 0.08,
sizeMultiplier: 0.8,
hideMuted: true,
},
inspection: {
defaultPriorityThreshold: 0.52,
backgroundSampleRate: 0.9,
defaultAlpha: 0.14,
mutedAlpha: 0.04,
inactiveAlpha: 0.03,
neighborAlpha: 0.1,
sizeMultiplier: 0.88,
hideMuted: true,
},
},
},
contextCaps: {
full: {
overview: 0,
structure: 12,
inspection: 24,
},
grouped: {
overview: 6,
structure: 8,
inspection: 10,
},
focused: {
overview: 24,
structure: 36,
inspection: 48,
},
},
fullGraphStructure: {
ambientBackboneAlpha: 0.12,
backboneAlpha: 0.08,
bridgeAlpha: 0.14,
bridgeCurvePriorityThreshold: 0.78,
bridgeCurveStrength: 0.1,
backboneMaxSize: 0.5,
bridgeMaxSize: 0.7,
structureEdgeAlpha: 0.12,
inspectionEdgeAlpha: 0.1,
},
// Staged rollout — set mode to "auto" to enable cross-community curve rendering.
// Currently "off" so the canvas overlay layer is inactive in production.
fullGraphStructureLayer: {
mode: "off",
minimumLiteralEdges: 24,
minimumCurves: 8,
maxCurves: 64,
bridgeAlpha: 0.16,
backboneAlpha: 0.1,
bridgeLineWidth: 0.9,
backboneLineWidth: 0.62,
curveStrength: 0.12,
},
},
interaction: {
localContextAlpha: 0.32,
hoverContextAlpha: 0.32,
selectedEdgeAlpha: 0.6,
pathEdgeAlpha: 0.76,
localContextMaxSize: 0.6,
selectedEdgeMaxSize: 1.0,
pathEdgeMaxSize: 1.4,
pathOverlayAlpha: 0.16,
},
overlays: {
hoverGlowAlpha: 0.18,
@@ -473,6 +876,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 +962,7 @@ export const GRAPH_THEME: GraphTheme = {
maxGroups: 8,
},
diagnostics: {
enabledInDev: import.meta.env.DEV,
enabledInDev: IS_DEV,
},
},
};
@@ -570,7 +1002,7 @@ export function withAlpha(color: string | undefined, alpha: number): string {
}
if (color.startsWith("rgba(")) {
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
return color.replace(/rgba\((.*?),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
}
if (color.startsWith("rgb(")) {
@@ -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,11 +5,14 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDiagnosticsSnapshot,
GraphDistanceVisualState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphLayoutSource,
GraphLayoutStatus,
GraphRuntimeDiagnosticsSnapshot,
GraphTemporalState,
GraphViewMode,
} from "./types";
@@ -22,6 +25,8 @@ export interface GraphSceneRuntime {
scene: unknown;
graph: GraphSceneGraph;
displayGraph: GraphSceneGraph;
graphVersion: number;
layoutMode?: GraphDisplayMeta["layoutMode"];
requestRender: () => void;
getCameraState: () => GraphCameraState | null;
}
@@ -31,16 +36,23 @@ export interface GraphSceneEventMap {
onEdgeSelect?: (edgeId: string) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
}
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[];
distanceVisualState?: GraphDistanceVisualState;
effectsState: GraphEffectsState;
temporalState?: GraphTemporalState | null;
isLayoutRunning: boolean;
@@ -56,6 +68,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;
}
@@ -5,7 +5,7 @@ import type { NodeDisplayData, RenderParams } from "sigma/types";
import { floatColor } from "sigma/utils";
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import { GRAPH_THEME, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
type SemanticaNodeDrawData = {
x: number;
@@ -16,39 +16,106 @@ type SemanticaNodeDrawData = {
shellColor?: string;
coreScale?: number;
borderColor?: string;
borderSize?: number;
ringColor?: string;
ringSize?: number;
entityShape?: GraphEntityShapeVariant;
entityShapeKind?: number;
entityAspectRatio?: number;
nodeType?: string;
};
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
precision highp float;
varying vec4 v_coreColor;
varying vec4 v_shellColor;
varying vec4 v_ringColor;
varying vec4 v_bodyColor;
varying vec4 v_glyphColor;
varying vec4 v_outlineColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
varying float v_outlineSize;
varying float v_glyphScale;
varying float v_shapeKind;
varying float v_aspectRatio;
uniform float u_correctionRatio;
const float bias = 255.0 / 254.0;
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
float discMetric(vec2 point) {
return length(point);
float hexMetric(vec2 point) {
vec2 q = abs(point);
return max(q.y, q.x * 0.8660254 + q.y * 0.5);
}
vec2 rotate45(vec2 point) {
const float invSqrt2 = 0.70710678;
return vec2(
(point.x - point.y) * invSqrt2,
(point.x + point.y) * invSqrt2
);
}
float roundedBoxDistance(vec2 point, vec2 halfSize, float radius) {
vec2 q = abs(point) - halfSize + vec2(radius);
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
}
float capsuleDistance(vec2 point) {
vec2 q = vec2(max(abs(point.x) - 0.44, 0.0), point.y);
return length(q) - 0.56;
}
float shapeDistance(vec2 point, float shapeKind) {
if (shapeKind < 0.5) {
return length(point) - 1.0;
}
if (shapeKind < 1.5) {
return hexMetric(point) - 0.92;
}
if (shapeKind < 2.5) {
return roundedBoxDistance(rotate45(point), vec2(0.58, 0.58), 0.18);
}
if (shapeKind < 3.5) {
return capsuleDistance(point);
}
if (shapeKind < 4.5) {
return roundedBoxDistance(point, vec2(0.78, 0.78), 0.24);
}
return length(point) - 1.0;
}
float glyphDistance(vec2 point, float shapeKind, float scale) {
vec2 scaled = point / max(scale, 0.08);
if (shapeKind < 0.5) {
return 1.0;
}
if (shapeKind < 1.5) {
return abs(hexMetric(scaled) - 0.74) - 0.055;
}
if (shapeKind < 2.5) {
return abs(abs(scaled.x) + abs(scaled.y) - 0.78) - 0.045;
}
if (shapeKind < 3.5) {
return roundedBoxDistance(scaled, vec2(0.56, 0.07), 0.07);
}
if (shapeKind < 4.5) {
return abs(roundedBoxDistance(scaled, vec2(0.48, 0.48), 0.18)) - 0.045;
}
return 1.0;
}
void main(void) {
vec2 unit = v_diffVector / max(v_radius, 0.0001);
float metric = discMetric(unit);
vec2 unit = vec2(
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
v_diffVector.y / max(v_radius, 0.0001)
);
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
float distance = shapeDistance(unit, v_shapeKind);
float alpha = 1.0 - smoothstep(-aa, aa, distance);
#ifdef PICKING_MODE
if (alpha <= 0.0) {
@@ -63,52 +130,63 @@ void main(void) {
return;
}
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
float ringStart = max(0.0, 1.0 - ringNorm);
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
float outlineNorm = clamp(v_outlineSize / max(v_radius, 1.0), 0.035, 0.28);
float outlineBlend = 1.0 - smoothstep(-outlineNorm - aa, -outlineNorm + aa, distance);
float isOutline = 1.0 - outlineBlend;
float topLight = clamp((-unit.y + 0.85) * 0.5, 0.0, 1.0);
vec4 color = v_bodyColor;
color.rgb += vec3(0.014) * pow(topLight, 2.2);
if (ringNorm > 0.0 && metric >= ringStart) {
color = v_ringColor;
if (isOutline > 0.0) {
color = mix(color, v_outlineColor, isOutline);
}
float glyphVisible = step(7.25, v_radius) * step(0.13, v_glyphScale) * step(0.5, v_shapeKind) * (1.0 - step(4.5, v_shapeKind));
float glyph = (1.0 - smoothstep(-aa * 1.4, aa * 1.4, glyphDistance(unit, v_shapeKind, clamp(v_glyphScale, 0.16, 0.52)))) * glyphVisible;
if (glyph > 0.0 && distance < -outlineNorm) {
color = mix(color, v_glyphColor, glyph * 0.38);
}
color.a *= alpha;
gl_FragColor = color;
#endif
}
`;
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
const ENTITY_TOKEN_VERTEX_SHADER = /* glsl */ `
attribute vec4 a_id;
attribute vec2 a_position;
attribute float a_size;
attribute float a_angle;
attribute vec4 a_coreColor;
attribute vec4 a_shellColor;
attribute vec4 a_ringColor;
attribute float a_ringSize;
attribute float a_coreScale;
attribute vec4 a_bodyColor;
attribute vec4 a_glyphColor;
attribute vec4 a_outlineColor;
attribute float a_outlineSize;
attribute float a_glyphScale;
attribute float a_shapeKind;
attribute float a_aspectRatio;
uniform mat3 u_matrix;
uniform float u_sizeRatio;
uniform float u_correctionRatio;
varying vec4 v_coreColor;
varying vec4 v_shellColor;
varying vec4 v_ringColor;
varying vec4 v_bodyColor;
varying vec4 v_glyphColor;
varying vec4 v_outlineColor;
varying vec4 v_color;
varying vec2 v_diffVector;
varying float v_radius;
varying float v_ringSize;
varying float v_coreScale;
varying float v_outlineSize;
varying float v_glyphScale;
varying float v_shapeKind;
varying float v_aspectRatio;
const float bias = 255.0 / 254.0;
void main() {
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
float aspect = max(a_aspectRatio, 1.0);
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
vec2 position = a_position + diffVector;
gl_Position = vec4(
@@ -119,22 +197,24 @@ void main() {
v_diffVector = diffVector;
v_radius = size / 2.0;
v_ringSize = a_ringSize;
v_coreScale = a_coreScale;
v_outlineSize = a_outlineSize;
v_glyphScale = a_glyphScale;
v_shapeKind = a_shapeKind;
v_aspectRatio = aspect;
#ifdef PICKING_MODE
v_color = a_id;
#else
v_coreColor = a_coreColor;
v_shellColor = a_shellColor;
v_ringColor = a_ringColor;
v_bodyColor = a_bodyColor;
v_glyphColor = a_glyphColor;
v_outlineColor = a_outlineColor;
#endif
v_color.a *= bias;
}
`;
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
static readonly ANGLE_1 = 0;
static readonly ANGLE_2 = (2 * Math.PI) / 3;
static readonly ANGLE_3 = (4 * Math.PI) / 3;
@@ -146,47 +226,52 @@ class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[
getDefinition() {
return {
VERTICES: 3,
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
METHOD: WebGLRenderingContext.TRIANGLES,
UNIFORMS: MINERAL_DISC_UNIFORMS,
UNIFORMS: ENTITY_TOKEN_UNIFORMS,
ATTRIBUTES: [
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_bodyColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_glyphColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_outlineColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
{ name: "a_outlineSize", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_glyphScale", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_shapeKind", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_aspectRatio", size: 1, type: WebGLRenderingContext.FLOAT },
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
],
CONSTANT_ATTRIBUTES: [
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
],
CONSTANT_DATA: [
[MineralDiscNodeProgram.ANGLE_1],
[MineralDiscNodeProgram.ANGLE_2],
[MineralDiscNodeProgram.ANGLE_3],
[EntityTokenNodeProgram.ANGLE_1],
[EntityTokenNodeProgram.ANGLE_2],
[EntityTokenNodeProgram.ANGLE_3],
],
};
}
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
const array = this.array;
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
const outlineColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
const outlineSize = Math.max(data.ringSize || 0, data.borderSize || 0.7);
array[startIndex++] = data.x;
array[startIndex++] = data.y;
array[startIndex++] = data.size;
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
array[startIndex++] = floatColor(ringColor);
array[startIndex++] = data.ringSize || 0;
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
array[startIndex++] = floatColor(outlineColor);
array[startIndex++] = outlineSize;
array[startIndex++] = data.coreScale ?? 0.22;
array[startIndex++] = data.entityShapeKind ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].shapeKind;
array[startIndex++] = data.entityAspectRatio ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].aspectRatio;
array[startIndex++] = nodeIndex;
}
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof ENTITY_TOKEN_UNIFORMS)[number]>): void {
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
@@ -326,7 +411,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
...DEFAULT_NODE_PROGRAM_CLASSES,
circle: MineralDiscNodeProgram,
circle: EntityTokenNodeProgram,
};
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
+112 -1
View File
@@ -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,45 @@ 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 GraphFullEdgeClass = "hidden" | "backbone" | "bridge" | "local-context" | "selected" | "path" | "muted";
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
export type GraphDistanceVisualMode = "off" | "ego" | "heatmap" | "structural" | "semantic";
export type GraphDistanceVisualStatus = "idle" | "loading" | "ready" | "unavailable" | "error";
export interface GraphDistanceBucketCounts {
anchor: number;
oneHop: number;
twoHop: number;
threeHopPlus: number;
outside: number;
}
export type GraphHeatmapSaturationMode = "normal" | "sampled";
export interface GraphHeatmapRenderSnapshot {
visibleNodeIds: string[];
ringCounts: GraphDistanceBucketCounts;
renderedRingCounts: GraphDistanceBucketCounts;
saturationMode: GraphHeatmapSaturationMode;
}
export interface GraphDistanceVisualState {
mode: GraphDistanceVisualMode;
anchorNodeId: string | null;
anchorLabel?: string | null;
maxHops: number;
structuralDistances: Record<string, number>;
semanticScores: Record<string, number>;
distanceCounts?: GraphDistanceBucketCounts;
outsideCount?: number;
heatmapVisibleNodeIds?: string[];
heatmapRingCounts?: GraphDistanceBucketCounts;
heatmapRenderedRingCounts?: GraphDistanceBucketCounts;
heatmapSaturationMode?: GraphHeatmapSaturationMode;
semanticNeighborCount?: number;
status: GraphDistanceVisualStatus;
error?: string | null;
}
export interface GraphCameraState {
x: number;
@@ -31,6 +70,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"
@@ -69,11 +130,51 @@ export interface GraphEffectAvailability {
segmentCap?: number;
}
export type GraphFullEdgeClassCounts = Record<GraphFullEdgeClass, number>;
export interface GraphFullEdgeClassDiagnostics {
mode: GraphViewMode;
zoomTier: GraphInteractionState["zoomTier"];
totalEdges: number;
visibleEdges: number;
counts: GraphFullEdgeClassCounts;
updatedAt: number;
}
export type GraphStructureLayerDisabledReason =
| "non-full-mode"
| "layout-running"
| "enough-literal-edges"
| "no-eligible-edges"
| "invalid-layer"
| "cache-empty"
| "disabled";
export interface GraphStructureLayerDiagnostics {
enabled: boolean;
disabledReason: GraphStructureLayerDisabledReason | null;
curveCount: number;
bridgeCurveCount: number;
backboneCurveCount: number;
cacheKey: string;
lastDrawAt: number | null;
}
export interface GraphRuntimeDiagnosticsSnapshot {
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
edgeClasses?: GraphFullEdgeClassDiagnostics;
structureLayer?: GraphStructureLayerDiagnostics;
distanceVisual?: GraphDistanceVisualState;
}
export interface GraphDiagnosticsSnapshot {
interactionState: GraphInteractionState;
activePluginIds: string[];
openPanelIds: string[];
effectsState: GraphEffectsState;
edgeClasses?: GraphFullEdgeClassDiagnostics;
structureLayer?: GraphStructureLayerDiagnostics;
distanceVisual?: GraphDistanceVisualState;
effectAvailability: {
pathPulse: GraphEffectAvailability;
pathFlow: GraphEffectAvailability;
@@ -245,6 +346,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 +365,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 {
@@ -10,9 +10,11 @@ import {
withAlpha,
type GraphBadgeKind,
type GraphEdgeVariant,
type GraphEntityShapeVariant,
type GraphLabelVisibilityPolicy,
type GraphNodeShapeVariant,
} from "./graphTheme";
import { classifyEntityShape } from "./graphEntityShape";
import { createGraphLoadProgress } from "./graphLoading";
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
@@ -196,6 +198,15 @@ function getProvenanceCount(properties: Record<string, unknown>): number {
);
}
function resolveEntityShape(attributes: NodeAttributes, semanticGroup: string): GraphEntityShapeVariant {
return classifyEntityShape(
attributes.nodeType,
semanticGroup,
attributes.content,
attributes.properties as Record<string, unknown> | undefined,
);
}
function resolveNodeVariantMetadata(
baseColor: string,
sizeRatio: number,
@@ -548,6 +559,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
const properties = attributes.properties as Record<string, unknown>;
const entityShape = resolveEntityShape(attributes, semanticGroup);
const providedX = readFiniteCoordinate(properties?.x);
const providedY = readFiniteCoordinate(properties?.y);
const seededPosition = seededPositions?.get(id);
@@ -575,6 +587,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
strokeColor: darkenHex(baseColor, 112),
borderColor: darkenHex(baseColor, 112),
borderSize: 0.72,
entityShape,
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
} as NodeAttributes,
};
@@ -598,6 +611,12 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
parallelOffsets.set(pairKey, parallelIndex + 1);
const parallelCount = parallelCounts.get(pairKey) ?? 1;
const normalizedWeight = clamp(0, Math.log1p(Math.max(Number(edge.weight) || 1, 1)) / 6, 1);
const edgeVisualPriority = clamp(
0,
Math.sqrt(Math.max(sourcePriority, 0) * Math.max(targetPriority, 0)) * 0.72 + normalizedWeight * 0.28,
1,
);
return {
id: edge.id,
@@ -617,7 +636,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
color: GRAPH_THEME.palette.muted.edgeStructure,
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
visualPriority: Math.max(sourcePriority, targetPriority),
visualPriority: edgeVisualPriority,
isBidirectional,
edgeFamily: isBidirectional ? "bidirectional" : "line",
curveGroup: curveGroupForPair(edge.source, edge.target),
@@ -0,0 +1,913 @@
import { useRef, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
FileUp,
Globe,
Loader2,
Plus,
X,
} from "lucide-react";
type LoaderMode = "url" | "file" | "create";
type CreateMode = "scratch" | "data" | "text";
interface OntologyPreview {
uri: string;
name: string;
description?: string;
namespace?: string;
version?: string;
license?: string;
format: string;
estimated_triples: number;
source_url?: string;
}
interface LoaderProps {
onLoaded: () => void;
onClose: () => void;
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
padding: "2px 8px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.07em",
textTransform: "uppercase" as const,
background: `${color}18`,
border: `1px solid ${color}33`,
color,
}}
>
{label}
</span>
);
}
function FieldGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<label style={fieldLabelStyle}>{label}</label>
{children}
</div>
);
}
function Input({
value,
onChange,
placeholder,
type = "text",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={inputStyle}
/>
);
}
function Textarea({
value,
onChange,
placeholder,
rows = 5,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
style={{ ...inputStyle, resize: "vertical", fontFamily: "monospace" }}
/>
);
}
function PreviewCard({ preview }: { preview: OntologyPreview }) {
return (
<div style={previewCardStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<CheckCircle2 size={16} color="#4cc38a" />
<span style={{ color: "#4cc38a", fontSize: 12, fontWeight: 700 }}>
Preview ready
</span>
<Badge label={preview.format} color="#58a6ff" />
</div>
<div style={previewTitleStyle}>{preview.name}</div>
{preview.description && (
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 6, lineHeight: 1.5 }}>
{preview.description}
</div>
)}
<div style={previewGridStyle}>
<PreviewRow label="Namespace" value={preview.namespace || preview.uri} mono />
{preview.version && <PreviewRow label="Version" value={preview.version} />}
{preview.license && <PreviewRow label="License" value={preview.license} />}
<PreviewRow
label="Estimated triples"
value={preview.estimated_triples.toLocaleString()}
/>
</div>
</div>
);
}
function PreviewRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em" }}>
{label}
</span>
<span
style={{
color: "#c6d4e3",
fontSize: 11,
fontFamily: mono ? "monospace" : undefined,
wordBreak: "break-all",
}}
>
{value}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// URL Import panel
// ---------------------------------------------------------------------------
function URLImportPanel({ onLoaded }: { onLoaded: () => void }) {
const [url, setUrl] = useState("");
const [format, setFormat] = useState("");
const [customName, setCustomName] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [preview, setPreview] = useState<OntologyPreview | null>(null);
const [previewState, setPreviewState] = useState<"idle" | "loading" | "error">("idle");
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const handlePreview = async () => {
if (!url.trim()) return;
setPreviewState("loading");
setPreview(null);
setErrorMsg("");
try {
const res = await fetch("/api/ontology/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim(), format: format || undefined }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
throw new Error(err.detail || "Preview failed");
}
setPreview(await res.json());
setPreviewState("idle");
} catch (e) {
setPreviewState("error");
setErrorMsg(e instanceof Error ? e.message : "Could not fetch preview");
}
};
const handleLoad = async () => {
if (!url.trim()) return;
setLoadState("loading");
try {
const res = await fetch("/api/ontology/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url.trim(),
format: format || undefined,
name: customName || undefined,
description: description || undefined,
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Load failed" }));
throw new Error(err.detail || "Load failed");
}
setLoadState("success");
setTimeout(() => {
setLoadState("idle");
onLoaded();
}, 1200);
} catch (e) {
setLoadState("error");
setErrorMsg(e instanceof Error ? e.message : "Load failed");
}
};
return (
<div style={panelBodyStyle}>
<FieldGroup label="Ontology URL">
<div style={{ display: "flex", gap: 8 }}>
<input
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setPreview(null);
setPreviewState("idle");
}}
placeholder="https://schema.org/version/latest/schema.ttl"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={handlePreview}
disabled={!url.trim() || previewState === "loading"}
style={previewBtnStyle}
>
{previewState === "loading" ? (
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
) : (
"Fetch Preview"
)}
</button>
</div>
</FieldGroup>
{previewState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
{preview && <PreviewCard preview={preview} />}
<button
onClick={() => setShowAdvanced((v) => !v)}
style={advancedToggleStyle}
>
<ChevronDown
size={13}
style={{ transform: showAdvanced ? "rotate(180deg)" : undefined, transition: "200ms" }}
/>
Advanced options
</button>
{showAdvanced && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<FieldGroup label="Format override">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
style={selectStyle}
>
<option value="">Auto-detect</option>
<option value="turtle">Turtle (.ttl)</option>
<option value="xml">RDF/XML (.rdf, .owl)</option>
<option value="nt">N-Triples (.nt)</option>
<option value="json-ld">JSON-LD (.jsonld)</option>
</select>
</FieldGroup>
<FieldGroup label="Custom display name">
<Input value={customName} onChange={setCustomName} placeholder="Leave blank to use ontology title" />
</FieldGroup>
<FieldGroup label="Description">
<Input value={description} onChange={setDescription} placeholder="Optional description" />
</FieldGroup>
<FieldGroup label="Tags (comma-separated)">
<Input value={tags} onChange={setTags} placeholder="e.g. biology, upper-ontology" />
</FieldGroup>
</div>
)}
{loadState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology loaded successfully</span>
</div>
)}
{loadState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
<button
onClick={handleLoad}
disabled={!url.trim() || loadState === "loading"}
style={primaryBtnStyle}
>
{loadState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Loading
</>
) : (
<>
<Globe size={13} />
Load Ontology
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// File Upload panel
// ---------------------------------------------------------------------------
function FileUploadPanel({ onLoaded }: { onLoaded: () => void }) {
const fileRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState("");
const [content, setContent] = useState("");
const [format, setFormat] = useState("");
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [dragging, setDragging] = useState(false);
const handleFile = (file: File) => {
setFileName(file.name);
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const fmtMap: Record<string, string> = {
ttl: "turtle", rdf: "xml", owl: "xml", xml: "xml",
nt: "nt", jsonld: "json-ld", json: "json-ld",
};
// Leave format empty for unknown extensions so the backend auto-detects
setFormat(fmtMap[ext] ?? "");
const reader = new FileReader();
reader.onload = (e) => setContent(e.target?.result as string || "");
reader.readAsText(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
};
const handleLoad = async () => {
if (!content) return;
setLoadState("loading");
try {
const res = await fetch("/api/ontology/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Omit format when empty so the backend _detect_format() runs
body: JSON.stringify({ content, ...(format ? { format } : {}) }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Load failed" }));
throw new Error(err.detail || "Load failed");
}
setLoadState("success");
setTimeout(() => {
setLoadState("idle");
onLoaded();
}, 1200);
} catch (e) {
setLoadState("error");
setErrorMsg(e instanceof Error ? e.message : "Load failed");
}
};
return (
<div style={panelBodyStyle}>
<div
style={{
...dropzoneStyle,
borderColor: dragging
? "rgba(74,163,255,0.5)"
: "rgba(127,208,255,0.18)",
background: dragging ? "rgba(74,163,255,0.06)" : undefined,
}}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileRef.current?.click()}
>
<FileUp size={24} color="#4aa3ff" />
{fileName ? (
<div style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 600 }}>{fileName}</div>
) : (
<>
<div style={{ color: "#8fa8c6", fontSize: 13 }}>
Drop a file here or <span style={{ color: "#4aa3ff" }}>browse</span>
</div>
<div style={{ color: "#5a7a9a", fontSize: 11 }}>
.ttl · .rdf · .owl · .xml · .nt · .jsonld · .json · .n3
</div>
</>
)}
<input
ref={fileRef}
type="file"
accept=".ttl,.rdf,.owl,.nt,.jsonld,.json,.xml,.n3"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{content && (
<FieldGroup label="Format">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
style={selectStyle}
>
<option value="turtle">Turtle</option>
<option value="xml">RDF/XML</option>
<option value="nt">N-Triples</option>
<option value="json-ld">JSON-LD</option>
</select>
</FieldGroup>
)}
{loadState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology loaded successfully {fileName}</span>
</div>
)}
{loadState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={handleLoad}
disabled={!content || loadState === "loading"}
style={primaryBtnStyle}
>
{loadState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Loading
</>
) : (
<>
<FileUp size={13} />
Load File
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create New panel
// ---------------------------------------------------------------------------
function CreateNewPanel({ onLoaded }: { onLoaded: () => void }) {
const [createMode, setCreateMode] = useState<CreateMode>("scratch");
const [namespace, setNamespace] = useState("https://example.org/ontology/");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [sampleData, setSampleData] = useState("");
const [schemaText, setSchemaText] = useState("");
const [createState, setCreateState] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const handleCreate = async () => {
if (!name.trim() || !namespace.trim()) return;
setCreateState("loading");
try {
const res = await fetch("/api/ontology/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: createMode,
namespace: namespace.trim(),
name: name.trim(),
description: description || undefined,
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
sample_data: createMode === "data" ? sampleData : undefined,
schema_text: createMode === "text" ? schemaText : undefined,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Create failed" }));
throw new Error(err.detail || "Create failed");
}
setCreateState("success");
setTimeout(() => {
setCreateState("idle");
onLoaded();
}, 1200);
} catch (e) {
setCreateState("error");
setErrorMsg(e instanceof Error ? e.message : "Create failed");
}
};
return (
<div style={panelBodyStyle}>
<div style={{ display: "flex", gap: 6 }}>
{(["scratch", "data", "text"] as CreateMode[]).map((m) => (
<button
key={m}
onClick={() => setCreateMode(m)}
style={{
...modeTabBase,
...(createMode === m ? modeTabActive : modeTabIdle),
}}
>
{m === "scratch" ? "From Scratch" : m === "data" ? "From Data" : "From Text"}
</button>
))}
</div>
<FieldGroup label="Display Name *">
<Input value={name} onChange={setName} placeholder="My Ontology" />
</FieldGroup>
<FieldGroup label="Namespace URI *">
<Input value={namespace} onChange={setNamespace} placeholder="https://example.org/onto/" />
</FieldGroup>
<FieldGroup label="Description">
<Input value={description} onChange={setDescription} placeholder="Optional description" />
</FieldGroup>
<FieldGroup label="Tags (comma-separated)">
<Input value={tags} onChange={setTags} placeholder="e.g. internal, draft" />
</FieldGroup>
{createMode === "data" && (
<FieldGroup label="Sample Data (JSON or CSV)">
<Textarea
value={sampleData}
onChange={setSampleData}
placeholder={'[{"name": "Alice", "age": 30, "city": "Berlin"}]'}
rows={6}
/>
</FieldGroup>
)}
{createMode === "text" && (
<FieldGroup label="Schema Requirements (natural language)">
<Textarea
value={schemaText}
onChange={setSchemaText}
placeholder="Describe the ontology you need. E.g.: I need an ontology for a hospital domain with patients, doctors, appointments, and medications."
rows={6}
/>
</FieldGroup>
)}
{createState === "success" && (
<div style={successBoxStyle}>
<CheckCircle2 size={13} />
<span>Ontology created and opened in the Registry</span>
</div>
)}
{createState === "error" && (
<div style={errorBoxStyle}>
<AlertCircle size={13} />
<span>{errorMsg}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={handleCreate}
disabled={!name.trim() || !namespace.trim() || createState === "loading"}
style={primaryBtnStyle}
>
{createState === "loading" ? (
<>
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
Creating
</>
) : (
<>
<Plus size={13} />
Create Ontology
</>
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main OntologyLoader modal
// ---------------------------------------------------------------------------
export function OntologyLoader({ onLoaded, onClose }: LoaderProps) {
const [mode, setMode] = useState<LoaderMode>("url");
return (
<div style={overlayStyle} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div style={modalStyle}>
<div style={modalHeaderStyle}>
<div>
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 800 }}>Load Ontology</div>
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 2 }}>
Import from URL, upload a file, or create a new ontology
</div>
</div>
<button onClick={onClose} style={closeIconBtnStyle}>
<X size={16} />
</button>
</div>
<div style={{ display: "flex", gap: 2, padding: "0 20px", borderBottom: "1px solid rgba(127,208,255,0.1)" }}>
{(["url", "file", "create"] as LoaderMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
...modalTabBase,
...(mode === m ? modalTabActive : modalTabIdle),
}}
>
{m === "url" ? (
<><Globe size={12} /> URL Import</>
) : m === "file" ? (
<><FileUp size={12} /> File Upload</>
) : (
<><Plus size={12} /> Create New</>
)}
</button>
))}
</div>
<div style={modalBodyStyle}>
{mode === "url" && <URLImportPanel onLoaded={onLoaded} />}
{mode === "file" && <FileUploadPanel onLoaded={onLoaded} />}
{mode === "create" && <CreateNewPanel onLoaded={onLoaded} />}
</div>
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(3,9,18,0.78)",
backdropFilter: "blur(6px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
width: "min(620px, 96vw)",
maxHeight: "88vh",
display: "flex",
flexDirection: "column",
borderRadius: 20,
border: "1px solid rgba(127,208,255,0.16)",
background: "linear-gradient(180deg, rgba(11,21,34,0.98), rgba(6,13,22,0.96))",
boxShadow: "0 32px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)",
overflow: "hidden",
};
const modalHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "20px 20px 16px",
};
const modalBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
};
const panelBodyStyle: React.CSSProperties = {
padding: "16px 20px 20px",
display: "flex",
flexDirection: "column",
gap: 14,
};
const modalTabBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
border: "none",
borderBottom: "2px solid transparent",
background: "transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
transition: "160ms ease",
};
const modalTabIdle: React.CSSProperties = {
color: "#8fa8c6",
};
const modalTabActive: React.CSSProperties = {
color: "#4aa3ff",
borderBottomColor: "#4aa3ff",
};
const closeIconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 4,
borderRadius: 8,
display: "grid",
placeItems: "center",
};
const fieldLabelStyle: React.CSSProperties = {
color: "#8fa8c6",
fontSize: 11,
fontWeight: 700,
letterSpacing: "0.05em",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(0,0,0,0.24)",
color: "#ebf3ff",
fontSize: 13,
outline: "none",
boxSizing: "border-box",
};
const selectStyle: React.CSSProperties = {
...inputStyle,
appearance: "none" as const,
cursor: "pointer",
};
const previewBtnStyle: React.CSSProperties = {
padding: "8px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.2)",
background: "rgba(74,163,255,0.08)",
color: "#7fd0ff",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
display: "inline-flex",
alignItems: "center",
gap: 6,
};
const primaryBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "9px 18px",
borderRadius: 10,
border: "1px solid rgba(74,163,255,0.3)",
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.1))",
color: "#7fd0ff",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
};
const advancedToggleStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
background: "transparent",
border: "none",
color: "#6a7f97",
fontSize: 12,
cursor: "pointer",
padding: 0,
};
const previewCardStyle: React.CSSProperties = {
padding: 14,
borderRadius: 10,
border: "1px solid rgba(76,195,138,0.18)",
background: "rgba(76,195,138,0.04)",
};
const previewTitleStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 15,
fontWeight: 800,
letterSpacing: "-0.03em",
};
const previewGridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
marginTop: 12,
};
const dropzoneStyle: React.CSSProperties = {
border: "2px dashed",
borderRadius: 12,
padding: "32px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
cursor: "pointer",
transition: "160ms ease",
};
const successBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(76,195,138,0.22)",
background: "rgba(76,195,138,0.06)",
color: "#4cc38a",
fontSize: 12,
};
const errorBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 8,
border: "1px solid rgba(255,157,175,0.22)",
background: "rgba(255,157,175,0.06)",
color: "#ff9daf",
fontSize: 12,
};
const modeTabBase: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 8,
border: "1px solid transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
};
const modeTabIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const modeTabActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.24)",
};
@@ -0,0 +1,915 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
BookOpen,
CheckCircle2,
ExternalLink,
GitMerge,
Layers,
Loader2,
Plus,
RefreshCw,
Search,
Trash2,
ToggleLeft,
ToggleRight,
} from "lucide-react";
import { OntologyLoader } from "./OntologyLoader";
import { OntologySearch } from "./OntologySearch";
import { SKOSVocabularyManager } from "./SKOSVocabularyManager";
interface OntologyEntry {
uri: string;
name: string;
description?: string;
format: string;
status: "published" | "draft" | "external";
source_url?: string;
version?: string;
class_count: number;
concept_count: number;
property_count: number;
loaded_at: string;
enabled: boolean;
tags: string[];
}
type RightPanel = "none" | "search" | "skos";
const STATUS_COLORS: Record<string, string> = {
published: "#4cc38a",
draft: "#f2b66d",
external: "#58a6ff",
};
const FORMAT_COLORS: Record<string, string> = {
turtle: "#9ee8d7",
xml: "#ff9daf",
"json-ld": "#f2b66d",
nt: "#d2a8ff",
unknown: "#6a7f97",
};
function StatusBadge({ status }: { status: string }) {
const color = STATUS_COLORS[status] || "#6a7f97";
return (
<span
style={{
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.07em",
textTransform: "uppercase" as const,
background: `${color}18`,
border: `1px solid ${color}33`,
color,
}}
>
{status}
</span>
);
}
function FormatBadge({ format }: { format: string }) {
const color = FORMAT_COLORS[format] || FORMAT_COLORS.unknown;
return (
<span
style={{
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: `${color}14`,
border: `1px solid ${color}28`,
color,
}}
>
{format}
</span>
);
}
function Stat({ value, label }: { value: number; label: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<span style={{ color: "#ebf3ff", fontSize: 14, fontWeight: 800 }}>
{value.toLocaleString()}
</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
</div>
);
}
function RegistryRow({
entry,
selected,
onSelect,
onToggle,
onRefresh,
onRemove,
}: {
entry: OntologyEntry;
selected: boolean;
onSelect: (e: OntologyEntry) => void;
onToggle: (uri: string) => void;
onRefresh: (uri: string) => void;
onRemove: (uri: string) => void;
}) {
const [busyToggle, setBusyToggle] = useState(false);
const [busyRefresh, setBusyRefresh] = useState(false);
const [busyRemove, setBusyRemove] = useState(false);
const handleToggle = async (ev: React.MouseEvent) => {
ev.stopPropagation();
setBusyToggle(true);
await onToggle(entry.uri);
setBusyToggle(false);
};
const handleRefresh = async (ev: React.MouseEvent) => {
ev.stopPropagation();
setBusyRefresh(true);
await onRefresh(entry.uri);
setBusyRefresh(false);
};
const handleRemove = async (ev: React.MouseEvent) => {
ev.stopPropagation();
if (!window.confirm(`Remove "${entry.name}" from the registry?`)) return;
setBusyRemove(true);
await onRemove(entry.uri);
setBusyRemove(false);
};
return (
<div
onClick={() => onSelect(entry)}
style={{
...rowStyle,
background: selected
? "rgba(74,163,255,0.1)"
: "rgba(255,255,255,0.02)",
borderColor: selected
? "rgba(127,208,255,0.26)"
: "rgba(127,208,255,0.1)",
opacity: entry.enabled ? 1 : 0.55,
}}
>
<div style={rowMainStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={rowNameStyle}>{entry.name}</span>
<StatusBadge status={entry.status} />
<FormatBadge format={entry.format} />
{!entry.enabled && (
<span style={disabledBadgeStyle}>Disabled</span>
)}
</div>
<div style={rowUriStyle}>{entry.uri}</div>
{entry.source_url && (
<a
href={entry.source_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
style={sourceLinkStyle}
>
<ExternalLink size={10} />
{entry.source_url.slice(0, 60)}{entry.source_url.length > 60 ? "…" : ""}
</a>
)}
</div>
<div style={rowStatsStyle}>
<Stat value={entry.class_count} label="Classes" />
<Stat value={entry.concept_count} label="Concepts" />
<Stat value={entry.property_count} label="Props" />
</div>
<div style={rowActionsStyle}>
<button
title={entry.enabled ? "Disable" : "Enable"}
onClick={handleToggle}
disabled={busyToggle}
style={actionBtnStyle}
>
{busyToggle ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : entry.enabled ? (
<ToggleRight size={15} color="#4cc38a" />
) : (
<ToggleLeft size={15} color="#6a7f97" />
)}
</button>
{entry.source_url && (
<button
title="Re-fetch from source URL"
onClick={handleRefresh}
disabled={busyRefresh}
style={actionBtnStyle}
>
{busyRefresh ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : (
<RefreshCw size={13} color="#58a6ff" />
)}
</button>
)}
<button
title="Remove from registry"
onClick={handleRemove}
disabled={busyRemove}
style={{ ...actionBtnStyle, color: "#ff9daf" }}
>
{busyRemove ? (
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
) : (
<Trash2 size={13} />
)}
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function OntologyManager() {
const [entries, setEntries] = useState<OntologyEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQ, setSearchQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [showLoader, setShowLoader] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<OntologyEntry | null>(null);
const [rightPanel, setRightPanel] = useState<RightPanel>("none");
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const fetchRegistry = useCallback(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (searchQ) params.set("q", searchQ);
// format/kind filters (owl/skos/internal/external) are applied client-side
// via filteredEntries; only text search is delegated to the backend
const res = await fetch(`/api/ontology/registry?${params}`);
if (!res.ok) throw new Error("Failed to load registry");
setEntries(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load registry");
} finally {
setLoading(false);
}
}, [searchQ, statusFilter]);
useEffect(() => {
fetchRegistry();
}, [fetchRegistry]);
const flashMsg = (type: "ok" | "err", text: string) => {
setActionMsg({ type, text });
setTimeout(() => setActionMsg(null), 3000);
};
const handleToggle = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/toggle`, {
method: "PATCH",
});
if (!res.ok) throw new Error("Toggle failed");
const data = await res.json();
setEntries((prev) =>
prev.map((e) => (e.uri === uri ? { ...e, enabled: data.enabled } : e))
);
} catch {
flashMsg("err", "Could not toggle ontology");
}
}, []);
const handleRefresh = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/refresh`, {
method: "POST",
});
if (!res.ok) throw new Error("Refresh failed");
flashMsg("ok", "Ontology refreshed");
fetchRegistry();
} catch {
flashMsg("err", "Refresh failed — check source URL");
}
}, [fetchRegistry]);
const handleRemove = useCallback(async (uri: string) => {
try {
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Remove failed");
setEntries((prev) => prev.filter((e) => e.uri !== uri));
if (selectedEntry?.uri === uri) setSelectedEntry(null);
flashMsg("ok", "Removed from registry");
} catch {
flashMsg("err", "Could not remove ontology");
}
}, [selectedEntry]);
const handleSelect = (entry: OntologyEntry) => {
setSelectedEntry((prev) => (prev?.uri === entry.uri ? null : entry));
setRightPanel("none");
};
const handleLoaded = () => {
setShowLoader(false);
fetchRegistry();
};
const filteredEntries = entries.filter((e) => {
if (statusFilter === "owl") return ["owl:Ontology"].includes(e.format) || e.format === "xml" || e.format === "turtle";
if (statusFilter === "skos") return e.concept_count > 0;
if (statusFilter === "internal") return e.status === "draft" || e.status === "published";
if (statusFilter === "external") return e.status === "external";
return true;
});
const isSKOS = selectedEntry ? selectedEntry.concept_count > 0 : false;
return (
<>
{showLoader && (
<OntologyLoader
onLoaded={handleLoaded}
onClose={() => setShowLoader(false)}
/>
)}
<div style={shellStyle}>
{/* Toolbar */}
<div style={toolbarStyle}>
<div style={searchBoxStyle}>
<Search size={14} color="#6a7f97" />
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="Search ontologies by name, URI, or namespace…"
style={searchInputStyle}
/>
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{(["all", "owl", "skos", "internal", "external"] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
style={{
...filterPillBase,
...(statusFilter === f ? filterPillActive : filterPillIdle),
}}
>
{f === "all" ? "All" : f.toUpperCase()}
</button>
))}
</div>
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
<button
onClick={() => setRightPanel((p) => (p === "search" ? "none" : "search"))}
style={{
...toolBtnStyle,
...(rightPanel === "search" ? toolBtnActive : {}),
}}
>
<Search size={13} />
Entity Search
</button>
<button
onClick={() => setShowLoader(true)}
style={primaryToolBtnStyle}
>
<Plus size={13} />
Load Ontology
</button>
</div>
</div>
{actionMsg && (
<div
style={{
...actionMsgStyle,
borderColor:
actionMsg.type === "ok"
? "rgba(76,195,138,0.22)"
: "rgba(255,157,175,0.22)",
background:
actionMsg.type === "ok"
? "rgba(76,195,138,0.06)"
: "rgba(255,157,175,0.06)",
color: actionMsg.type === "ok" ? "#4cc38a" : "#ff9daf",
}}
>
{actionMsg.type === "ok" ? (
<CheckCircle2 size={13} />
) : (
<AlertCircle size={13} />
)}
{actionMsg.text}
</div>
)}
{/* Main content area */}
<div style={mainAreaStyle}>
{/* Registry list */}
<div style={listPanelStyle}>
{loading ? (
<div style={centerStyle}>
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry</span>
</div>
) : error ? (
<div style={centerStyle}>
<AlertCircle size={22} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
</div>
) : filteredEntries.length === 0 ? (
<div style={emptyStateStyle}>
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
</div>
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
</div>
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
<Plus size={13} />
Load Ontology
</button>
</div>
) : (
<div style={listStyle}>
<div style={listHeaderStyle}>
<span style={listHeaderTextStyle}>
{filteredEntries.length} ontolog{filteredEntries.length === 1 ? "y" : "ies"}
</span>
</div>
{filteredEntries.map((entry) => (
<RegistryRow
key={entry.uri}
entry={entry}
selected={selectedEntry?.uri === entry.uri}
onSelect={handleSelect}
onToggle={handleToggle}
onRefresh={handleRefresh}
onRemove={handleRemove}
/>
))}
</div>
)}
</div>
{/* Right panel */}
{rightPanel === "search" && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>Entity Search</span>
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
</div>
<OntologySearch />
</div>
)}
{rightPanel === "none" && selectedEntry && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>{selectedEntry.name}</span>
<div style={{ display: "flex", gap: 6 }}>
{isSKOS && (
<button
onClick={() => setRightPanel("skos")}
style={browseBtnStyle}
>
<BookOpen size={12} />
Browse SKOS
</button>
)}
<button onClick={() => setSelectedEntry(null)} style={closePanelBtnStyle}>×</button>
</div>
</div>
<div style={detailBodyStyle}>
<DetailSection label="URI">
<span style={{ fontFamily: "monospace", fontSize: 11, wordBreak: "break-all", color: "#c6d4e3" }}>
{selectedEntry.uri}
</span>
</DetailSection>
{selectedEntry.description && (
<DetailSection label="Description">
<span style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{selectedEntry.description}
</span>
</DetailSection>
)}
{selectedEntry.source_url && (
<DetailSection label="Source URL">
<a
href={selectedEntry.source_url}
target="_blank"
rel="noreferrer"
style={{ color: "#58a6ff", fontSize: 11, wordBreak: "break-all" }}
>
{selectedEntry.source_url}
</a>
</DetailSection>
)}
{selectedEntry.version && (
<DetailSection label="Version">
<span style={{ color: "#c6d4e3", fontSize: 12 }}>{selectedEntry.version}</span>
</DetailSection>
)}
{selectedEntry.loaded_at && (
<DetailSection label="Loaded at">
<span style={{ color: "#c6d4e3", fontSize: 12 }}>
{new Date(selectedEntry.loaded_at).toLocaleString()}
</span>
</DetailSection>
)}
<div style={statRowStyle}>
<StatBlock value={selectedEntry.class_count} label="Classes" color="#d2a8ff" />
<StatBlock value={selectedEntry.concept_count} label="Concepts" color="#9ee8d7" />
<StatBlock value={selectedEntry.property_count} label="Properties" color="#f2b66d" />
</div>
{selectedEntry.tags.length > 0 && (
<DetailSection label="Tags">
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedEntry.tags.map((tag) => (
<span key={tag} style={tagChipStyle}>{tag}</span>
))}
</div>
</DetailSection>
)}
</div>
</div>
)}
{rightPanel === "skos" && selectedEntry && (
<div style={rightPanelStyle}>
<div style={rightPanelHeaderStyle}>
<span style={rightPanelTitleStyle}>SKOS {selectedEntry.name}</span>
<div style={{ display: "flex", gap: 6 }}>
<button onClick={() => setRightPanel("none")} style={browseBtnStyle}>
<Layers size={12} />
Registry Detail
</button>
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
</div>
</div>
<SKOSVocabularyManager schemeUri={selectedEntry.uri} />
</div>
)}
</div>
</div>
</>
);
}
/* ─── sub-components ─────────────────────────────────────────────────── */
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ borderTop: "1px solid rgba(255,255,255,0.05)", paddingTop: 10, paddingBottom: 2 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 4 }}>
{label}
</div>
{children}
</div>
);
}
function StatBlock({ value, label, color }: { value: number; label: string; color: string }) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 2, padding: "10px 6px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
<span style={{ color, fontSize: 18, fontWeight: 800 }}>{value.toLocaleString()}</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#0a1525",
overflow: "hidden",
};
const toolbarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px 18px",
borderBottom: "1px solid rgba(127,208,255,0.1)",
background: "rgba(5,12,22,0.72)",
flexWrap: "wrap",
flexShrink: 0,
};
const searchBoxStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 12px",
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.14)",
background: "rgba(0,0,0,0.24)",
flex: "0 0 280px",
};
const searchInputStyle: React.CSSProperties = {
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 12,
width: "100%",
};
const filterPillBase: React.CSSProperties = {
padding: "5px 11px",
borderRadius: 999,
border: "1px solid transparent",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
transition: "160ms ease",
};
const filterPillIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const filterPillActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.26)",
};
const toolBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.16)",
background: "rgba(74,163,255,0.06)",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};
const toolBtnActive: React.CSSProperties = {
background: "rgba(74,163,255,0.16)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.28)",
};
const primaryToolBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "7px 14px",
borderRadius: 9,
border: "1px solid rgba(74,163,255,0.3)",
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.08))",
color: "#7fd0ff",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
};
const actionMsgStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 18px",
fontSize: 12,
borderBottom: "1px solid",
flexShrink: 0,
};
const mainAreaStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const listPanelStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
overflowY: "auto",
borderRight: "1px solid rgba(127,208,255,0.08)",
};
const listStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
padding: "12px 14px",
gap: 8,
};
const listHeaderStyle: React.CSSProperties = {
paddingBottom: 6,
};
const listHeaderTextStyle: React.CSSProperties = {
color: "#6a7f97",
fontSize: 11,
fontWeight: 700,
};
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 14px",
borderRadius: 12,
border: "1px solid",
cursor: "pointer",
transition: "160ms ease",
};
const rowMainStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
};
const rowNameStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 14,
fontWeight: 700,
};
const rowUriStyle: React.CSSProperties = {
color: "#6a7f97",
fontSize: 11,
fontFamily: "monospace",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const sourceLinkStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 4,
color: "#58a6ff",
fontSize: 11,
textDecoration: "none",
};
const rowStatsStyle: React.CSSProperties = {
display: "flex",
gap: 16,
flexShrink: 0,
};
const rowActionsStyle: React.CSSProperties = {
display: "flex",
gap: 4,
flexShrink: 0,
};
const actionBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
cursor: "pointer",
padding: 5,
borderRadius: 6,
display: "grid",
placeItems: "center",
color: "#8fa8c6",
};
const rightPanelStyle: React.CSSProperties = {
width: 360,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(5,12,22,0.6)",
overflow: "hidden",
};
const rightPanelHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 16px",
borderBottom: "1px solid rgba(127,208,255,0.1)",
flexShrink: 0,
};
const rightPanelTitleStyle: React.CSSProperties = {
color: "#ebf3ff",
fontSize: 13,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const closePanelBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: "0 2px",
};
const browseBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
borderRadius: 7,
border: "1px solid rgba(127,208,255,0.18)",
background: "rgba(74,163,255,0.06)",
color: "#7fd0ff",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};
const detailBodyStyle: React.CSSProperties = {
padding: "14px 16px",
overflowY: "auto",
flex: 1,
display: "flex",
flexDirection: "column",
gap: 0,
};
const statRowStyle: React.CSSProperties = {
display: "flex",
gap: 6,
marginTop: 12,
marginBottom: 4,
};
const tagChipStyle: React.CSSProperties = {
padding: "3px 8px",
borderRadius: 999,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#8fa8c6",
fontSize: 11,
};
const disabledBadgeStyle: React.CSSProperties = {
padding: "2px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
background: "rgba(106,127,151,0.12)",
border: "1px solid rgba(106,127,151,0.2)",
color: "#6a7f97",
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 40,
};
const emptyStateStyle: React.CSSProperties = {
...centerStyle,
textAlign: "center",
};
const retryBtnStyle: React.CSSProperties = {
marginTop: 12,
padding: "6px 14px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.18)",
background: "transparent",
color: "#7fd0ff",
fontSize: 12,
cursor: "pointer",
};
@@ -0,0 +1,574 @@
import { useEffect, useRef, useState } from "react";
import {
AlertCircle,
BookOpen,
ChevronDown,
ChevronRight,
ExternalLink,
Loader2,
Search,
X,
} from "lucide-react";
interface SearchResult {
uri: string;
label: string;
type: string;
entity_type: string;
definition?: string;
source_ontology?: string;
namespace_prefix?: string;
}
interface EntityDetail {
uri: string;
label: string;
type: string;
entity_type: string;
definition?: string;
source_ontology?: string;
superclasses: string[];
subclasses: string[];
domain: string[];
range: string[];
instance_count: number;
properties: Record<string, unknown>;
}
const ENTITY_TYPE_COLORS: Record<string, string> = {
class: "#d2a8ff",
property: "#f2b66d",
individual: "#9ee8d7",
concept: "#58a6ff",
scheme: "#7fd0ff",
unknown: "#6a7f97",
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
class: "Class",
property: "Property",
individual: "Individual",
concept: "Concept",
scheme: "Scheme",
unknown: "Entity",
};
function TypeBadge({ entityType }: { entityType: string }) {
const color = ENTITY_TYPE_COLORS[entityType] || ENTITY_TYPE_COLORS.unknown;
return (
<span
style={{
padding: "1px 7px",
borderRadius: 999,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase" as const,
background: `${color}14`,
border: `1px solid ${color}28`,
color,
flexShrink: 0,
}}
>
{ENTITY_TYPE_LABELS[entityType] || entityType}
</span>
);
}
function UriRef({ uri }: { uri: string }) {
const short = uri.includes("#")
? uri.split("#").pop() || uri
: uri.split("/").pop() || uri;
return (
<span
title={uri}
style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}
>
{short}
</span>
);
}
function ResultRow({
result,
selected,
onSelect,
}: {
result: SearchResult;
selected: boolean;
onSelect: () => void;
}) {
return (
<div
onClick={onSelect}
style={{
display: "flex",
flexDirection: "column",
gap: 4,
padding: "10px 14px",
borderRadius: 10,
border: "1px solid",
cursor: "pointer",
transition: "160ms ease",
background: selected ? "rgba(74,163,255,0.1)" : "rgba(255,255,255,0.02)",
borderColor: selected ? "rgba(127,208,255,0.24)" : "rgba(127,208,255,0.08)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{result.label || result.uri}
</span>
<TypeBadge entityType={result.entity_type} />
</div>
<div style={{ color: "#6a7f97", fontSize: 10, fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{result.uri}
</div>
{result.definition && (
<div style={{ color: "#8fa8c6", fontSize: 12, lineHeight: 1.4, overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as const }}>
{result.definition}
</div>
)}
{result.source_ontology && (
<div style={{ color: "#5a7a9a", fontSize: 10 }}>
From: {result.source_ontology}
</div>
)}
</div>
);
}
function CollapsibleList({ label, items }: { label: string; items: string[] }) {
const [open, setOpen] = useState(false);
if (!items.length) return null;
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
style={collapseHdrStyle}
>
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>{label}</span>
<span style={{ color: "#6a7f97", fontSize: 10 }}>({items.length})</span>
</button>
{open && (
<div style={{ marginLeft: 16, marginTop: 4, display: "flex", flexDirection: "column", gap: 3 }}>
{items.slice(0, 12).map((uri) => (
<div key={uri} style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ color: "#6a7f97", fontSize: 10 }}></span>
<UriRef uri={uri} />
</div>
))}
{items.length > 12 && (
<span style={{ color: "#5a7a9a", fontSize: 10 }}>+{items.length - 12} more</span>
)}
</div>
)}
</div>
);
}
function DetailPanel({
uri,
onClose,
}: {
uri: string;
onClose: () => void;
}) {
const [detail, setDetail] = useState<EntityDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`)
.then((r) => {
if (!r.ok) throw new Error("Not found");
return r.json();
})
.then(setDetail)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [uri]);
return (
<div style={detailPanelStyle}>
<div style={detailHeaderStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<BookOpen size={14} color="#d2a8ff" />
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
Entity Detail
</span>
</div>
<button onClick={onClose} style={closeDetailBtnStyle}>
<X size={14} />
</button>
</div>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{detail && !loading && (
<div style={detailBodyStyle}>
<div style={{ marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginBottom: 4 }}>
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
{detail.label || detail.uri.split("/").pop()}
</h3>
<TypeBadge entityType={detail.entity_type} />
</div>
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
{detail.uri}
</div>
</div>
{detail.definition && (
<DetailSection label="Definition">
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{detail.definition}
</p>
</DetailSection>
)}
{detail.instance_count > 0 && (
<DetailSection label="Instances">
<span style={{ color: "#9ee8d7", fontSize: 14, fontWeight: 800 }}>
{detail.instance_count.toLocaleString()}
</span>
</DetailSection>
)}
<CollapsibleList label="Superclasses / Broader" items={detail.superclasses} />
<CollapsibleList label="Subclasses / Narrower" items={detail.subclasses} />
<CollapsibleList label="Domain" items={detail.domain} />
<CollapsibleList label="Range" items={detail.range} />
{detail.source_ontology && (
<DetailSection label="Source Ontology">
<span style={{ color: "#c6d4e3", fontSize: 12, fontFamily: "monospace" }}>
{detail.source_ontology}
</span>
</DetailSection>
)}
<a
href={detail.uri}
target="_blank"
rel="noreferrer"
style={openUriStyle}
>
<ExternalLink size={11} />
Open URI
</a>
</div>
)}
</div>
);
}
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Main OntologySearch component
// ---------------------------------------------------------------------------
export function OntologySearch() {
const [query, setQuery] = useState("");
const [entityType, setEntityType] = useState<string>("all");
const [results, setResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selectedUri, setSelectedUri] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const runSearch = async (q: string, type: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setSearching(true);
try {
const params = new URLSearchParams({ q: q.trim(), limit: "80" });
if (type !== "all") params.set("entity_type", type);
const res = await fetch(`/api/ontology/search?${params}`);
if (!res.ok) throw new Error("Search failed");
setResults(await res.json());
} catch {
setResults([]);
} finally {
setSearching(false);
}
};
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => runSearch(query, entityType), 320);
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
}, [query, entityType]);
return (
<div style={searchShellStyle}>
{/* Search input */}
<div style={searchTopStyle}>
<div style={searchBarStyle}>
<Search size={14} color="#6a7f97" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search classes, properties, concepts…"
style={searchInputStyle}
/>
{searching && <Loader2 size={13} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} />}
{query && !searching && (
<button onClick={() => { setQuery(""); setResults([]); }} style={clearBtnStyle}>
<X size={12} />
</button>
)}
</div>
<div style={typeFilterStyle}>
{(["all", "class", "property", "individual", "concept", "scheme"] as const).map((t) => (
<button
key={t}
onClick={() => setEntityType(t)}
style={{
...typeFilterBtnBase,
...(entityType === t ? typeFilterBtnActive : typeFilterBtnIdle),
}}
>
{t === "all" ? "All" : ENTITY_TYPE_LABELS[t] || t}
</button>
))}
</div>
</div>
{/* Results + detail */}
<div style={searchBodyStyle}>
<div style={resultListStyle}>
{!query && (
<div style={hintStyle}>
<Search size={20} color="rgba(74,163,255,0.2)" />
<span style={{ color: "#6a7f97", fontSize: 12, marginTop: 8 }}>
Type to search across all loaded ontologies
</span>
</div>
)}
{query && results.length === 0 && !searching && (
<div style={hintStyle}>
<span style={{ color: "#6a7f97", fontSize: 12 }}>No results for "{query}"</span>
</div>
)}
{results.length > 0 && (
<div style={{ padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ color: "#6a7f97", fontSize: 11, fontWeight: 700, marginBottom: 2 }}>
{results.length} result{results.length !== 1 ? "s" : ""}
</div>
{results.map((r) => (
<ResultRow
key={r.uri}
result={r}
selected={selectedUri === r.uri}
onSelect={() => setSelectedUri((prev) => (prev === r.uri ? null : r.uri))}
/>
))}
</div>
)}
</div>
{selectedUri && (
<DetailPanel uri={selectedUri} onClose={() => setSelectedUri(null)} />
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const searchShellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
};
const searchTopStyle: React.CSSProperties = {
padding: "12px 14px 10px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
display: "flex",
flexDirection: "column",
gap: 8,
flexShrink: 0,
};
const searchBarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 12px",
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.14)",
background: "rgba(0,0,0,0.24)",
};
const searchInputStyle: React.CSSProperties = {
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 13,
};
const clearBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#6a7f97",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const typeFilterStyle: React.CSSProperties = {
display: "flex",
gap: 5,
flexWrap: "wrap",
};
const typeFilterBtnBase: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 999,
border: "1px solid transparent",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
transition: "160ms ease",
};
const typeFilterBtnIdle: React.CSSProperties = {
background: "transparent",
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const typeFilterBtnActive: React.CSSProperties = {
background: "rgba(74,163,255,0.14)",
color: "#ebf3ff",
borderColor: "rgba(127,208,255,0.26)",
};
const searchBodyStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const resultListStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
minWidth: 0,
};
const hintStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 32,
};
const detailPanelStyle: React.CSSProperties = {
width: 320,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(3,9,18,0.5)",
overflow: "hidden",
};
const detailHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const closeDetailBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const detailBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "14px",
display: "flex",
flexDirection: "column",
gap: 0,
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 24,
};
const collapseHdrStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 5,
background: "transparent",
border: "none",
color: "#8fa8c6",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
padding: "6px 0",
width: "100%",
textAlign: "left",
};
const openUriStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
marginTop: 14,
color: "#58a6ff",
fontSize: 11,
textDecoration: "none",
};
@@ -0,0 +1,638 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
BookOpen,
ChevronDown,
ChevronRight,
Loader2,
Search,
X,
} from "lucide-react";
interface SKOSScheme {
uri: string;
title: string;
description?: string;
concept_count: number;
}
interface ConceptNode {
uri: string;
pref_label: string;
alt_labels?: string[];
description?: string;
notation?: string;
scheme_uri?: string;
parent_uri?: string;
children?: ConceptNode[];
}
interface SKOSConceptDetail {
uri: string;
pref_label: string;
alt_labels: string[];
hidden_labels: string[];
definition?: string;
scope_note?: string;
editorial_note?: string;
broader: string[];
narrower: string[];
related: string[];
exact_match: string[];
close_match: string[];
broad_match: string[];
narrow_match: string[];
scheme_uri?: string;
}
function countConcepts(nodes: ConceptNode[]): number {
return nodes.reduce((acc, n) => acc + 1 + countConcepts(n.children ?? []), 0);
}
function LabelChip({ label }: { label: string }) {
return (
<span style={chipStyle}>{label}</span>
);
}
function UriLink({ uri }: { uri: string }) {
const short = uri.includes("#") ? uri.split("#").pop() : uri.split("/").pop();
return (
<span title={uri} style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}>
{short || uri}
</span>
);
}
function ConceptDetailPanel({
uri,
onClose,
onNavigate,
}: {
uri: string;
onClose: () => void;
onNavigate: (uri: string) => void;
}) {
const [detail, setDetail] = useState<SKOSConceptDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`)
.then((r) => {
if (!r.ok) throw new Error("Concept not found");
return r.json();
})
.then(setDetail)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [uri]);
const renderUriList = (label: string, uris: string[]) => {
if (!uris.length) return null;
return (
<PropSection label={label}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{uris.map((u) => (
<button
key={u}
onClick={() => onNavigate(u)}
style={navLinkStyle}
>
<ChevronRight size={10} />
<UriLink uri={u} />
</button>
))}
</div>
</PropSection>
);
};
return (
<div style={detailPanelStyle}>
<div style={detailHeaderStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<BookOpen size={13} color="#9ee8d7" />
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700 }}>Concept Detail</span>
</div>
<button onClick={onClose} style={iconBtnStyle}>
<X size={14} />
</button>
</div>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{detail && !loading && (
<div style={detailBodyStyle}>
<div style={{ marginBottom: 14 }}>
<h3 style={{ margin: "0 0 4px", color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
{detail.pref_label}
</h3>
{detail.alt_labels.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
{detail.alt_labels.map((l) => <LabelChip key={l} label={l} />)}
</div>
)}
{detail.hidden_labels.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
{detail.hidden_labels.map((l) => (
<span key={l} style={{ ...chipStyle, opacity: 0.5, fontStyle: "italic" }}>{l}</span>
))}
</div>
)}
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
{uri}
</div>
</div>
{detail.definition && (
<PropSection label="Definition">
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
{detail.definition}
</p>
</PropSection>
)}
{detail.scope_note && (
<PropSection label="Scope Note">
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
{detail.scope_note}
</p>
</PropSection>
)}
{detail.editorial_note && (
<PropSection label="Editorial Note">
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
{detail.editorial_note}
</p>
</PropSection>
)}
{renderUriList("Broader", detail.broader)}
{renderUriList("Narrower", detail.narrower)}
{renderUriList("Related", detail.related)}
{renderUriList("Exact Match", detail.exact_match)}
{renderUriList("Close Match", detail.close_match)}
{renderUriList("Broad Match", detail.broad_match)}
{renderUriList("Narrow Match", detail.narrow_match)}
{detail.scheme_uri && (
<PropSection label="Concept Scheme">
<span style={{ color: "#c6d4e3", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all" }}>
{detail.scheme_uri}
</span>
</PropSection>
)}
</div>
)}
</div>
);
}
function PropSection({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
{label}
</div>
{children}
</div>
);
}
// ---------------------------------------------------------------------------
// Concept tree node
// ---------------------------------------------------------------------------
function ConceptTreeNode({
concept,
depth,
selectedUri,
onSelect,
}: {
concept: ConceptNode;
depth: number;
selectedUri: string | null;
onSelect: (uri: string) => void;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const children = concept.children ?? [];
const hasChildren = children.length > 0;
const isSelected = selectedUri === concept.uri;
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
paddingLeft: 10 + depth * 14,
paddingRight: 10,
paddingTop: 5,
paddingBottom: 5,
borderRadius: 7,
cursor: "pointer",
background: isSelected ? "rgba(74,163,255,0.12)" : "transparent",
transition: "120ms ease",
}}
onMouseEnter={(e) => {
if (!isSelected)
(e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.06)";
}}
onMouseLeave={(e) => {
if (!isSelected)
(e.currentTarget as HTMLDivElement).style.background = "transparent";
}}
>
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
style={expandBtnStyle}
>
{expanded ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</button>
) : (
<span style={{ width: 18, display: "inline-block", flexShrink: 0 }} />
)}
<span
onClick={() => onSelect(concept.uri)}
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: isSelected ? "#ebf3ff" : depth === 0 ? "#c6d4e3" : "#8fa8c6",
fontSize: depth === 0 ? 13 : 12,
fontWeight: depth === 0 ? 600 : 400,
}}
>
{concept.pref_label || concept.uri}
</span>
{hasChildren && (
<span style={{ color: "#5a7a9a", fontSize: 10, flexShrink: 0 }}>
{children.length}
</span>
)}
</div>
{expanded && hasChildren && children.map((child) => (
<ConceptTreeNode
key={child.uri}
concept={child}
depth={depth + 1}
selectedUri={selectedUri}
onSelect={onSelect}
/>
))}
</>
);
}
// ---------------------------------------------------------------------------
// Scheme panel
// ---------------------------------------------------------------------------
function SchemePanel({
scheme,
selectedUri,
onSelectConcept,
searchQuery,
}: {
scheme: SKOSScheme;
selectedUri: string | null;
onSelectConcept: (uri: string) => void;
searchQuery: string;
}) {
const [expanded, setExpanded] = useState(true);
const [hierarchy, setHierarchy] = useState<ConceptNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!expanded) return;
setLoading(true);
fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`)
.then((r) => (r.ok ? r.json() : []))
.then(setHierarchy)
.catch(() => setHierarchy([]))
.finally(() => setLoading(false));
}, [scheme.uri, expanded]);
const totalConcepts = countConcepts(hierarchy);
const filterConcepts = (nodes: ConceptNode[], q: string): ConceptNode[] => {
if (!q) return nodes;
return nodes.flatMap((n) => {
const match = (n.pref_label + " " + (n.alt_labels?.join(" ") ?? "") + " " + (n.description ?? ""))
.toLowerCase()
.includes(q.toLowerCase());
const filteredChildren = filterConcepts(n.children ?? [], q);
if (match || filteredChildren.length > 0) {
return [{ ...n, children: filteredChildren }];
}
return [];
});
};
const displayedConcepts = filterConcepts(hierarchy, searchQuery);
return (
<div style={schemePanelStyle}>
<button onClick={() => setExpanded((v) => !v)} style={schemeHeaderBtnStyle}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{expanded ? <ChevronDown size={13} color="#8fa8c6" /> : <ChevronRight size={13} color="#8fa8c6" />}
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.title}</span>
</div>
<span style={{ color: "#6a7f97", fontSize: 11 }}>
{loading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
</span>
</button>
{expanded && (
<div style={{ paddingBottom: 8 }}>
{loading ? (
<div style={{ padding: "10px 20px", display: "flex", alignItems: "center", gap: 8 }}>
<Loader2 size={12} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite" }} />
<span style={{ color: "#6a7f97", fontSize: 12 }}>Loading concepts</span>
</div>
) : displayedConcepts.length === 0 ? (
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
{searchQuery ? "No matching concepts" : "No concepts in this scheme"}
</div>
) : (
<div style={{ paddingTop: 2 }}>
{displayedConcepts.map((concept) => (
<ConceptTreeNode
key={concept.uri}
concept={concept}
depth={0}
selectedUri={selectedUri}
onSelect={onSelectConcept}
/>
))}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main SKOSVocabularyManager
// ---------------------------------------------------------------------------
interface Props {
schemeUri?: string;
}
export function SKOSVocabularyManager({ schemeUri }: Props) {
const [schemes, setSchemes] = useState<SKOSScheme[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQ, setSearchQ] = useState("");
const [selectedUri, setSelectedUri] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/ontology/skos/schemes")
.then((r) => (r.ok ? r.json() : []))
.then(setSchemes)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
const displayedSchemes = schemeUri
? schemes.filter((s) => s.uri === schemeUri)
: schemes;
return (
<div style={managerShellStyle}>
{/* Search bar */}
<div style={skosToolbarStyle}>
<div style={skosSearchBarStyle}>
<Search size={13} color="#6a7f97" />
<input
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder="Search labels and definitions…"
style={skosSearchInputStyle}
/>
{searchQ && (
<button onClick={() => setSearchQ("")} style={iconBtnStyle}>
<X size={11} />
</button>
)}
</div>
</div>
<div style={skosBodyStyle}>
{/* Scheme tree column */}
<div style={treeColStyle}>
{loading && (
<div style={centerStyle}>
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
</div>
)}
{error && (
<div style={centerStyle}>
<AlertCircle size={16} color="#ff9daf" />
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
</div>
)}
{!loading && !error && displayedSchemes.length === 0 && (
<div style={{ ...centerStyle, textAlign: "center", padding: 28 }}>
<BookOpen size={28} color="rgba(158,232,215,0.15)" />
<span style={{ color: "#8fa8c6", fontSize: 12, marginTop: 10 }}>
No SKOS concept schemes found
</span>
<span style={{ color: "#6a7f97", fontSize: 11, marginTop: 4, maxWidth: 220 }}>
Import a SKOS vocabulary to browse concepts here
</span>
</div>
)}
{!loading && displayedSchemes.map((scheme) => (
<SchemePanel
key={scheme.uri}
scheme={scheme}
selectedUri={selectedUri}
onSelectConcept={setSelectedUri}
searchQuery={searchQ}
/>
))}
</div>
{/* Concept detail panel */}
{selectedUri && (
<ConceptDetailPanel
uri={selectedUri}
onClose={() => setSelectedUri(null)}
onNavigate={setSelectedUri}
/>
)}
</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const managerShellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
};
const skosToolbarStyle: React.CSSProperties = {
padding: "10px 12px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const skosSearchBarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 7,
padding: "6px 10px",
borderRadius: 8,
border: "1px solid rgba(127,208,255,0.12)",
background: "rgba(0,0,0,0.22)",
};
const skosSearchInputStyle: React.CSSProperties = {
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "#ebf3ff",
fontSize: 12,
};
const skosBodyStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: "flex",
overflow: "hidden",
};
const treeColStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "8px 6px",
};
const detailPanelStyle: React.CSSProperties = {
width: 300,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderLeft: "1px solid rgba(127,208,255,0.1)",
background: "rgba(3,9,18,0.5)",
overflow: "hidden",
};
const detailHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 14px",
borderBottom: "1px solid rgba(127,208,255,0.08)",
flexShrink: 0,
};
const detailBodyStyle: React.CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "14px",
};
const schemePanelStyle: React.CSSProperties = {
borderRadius: 10,
border: "1px solid rgba(127,208,255,0.1)",
background: "rgba(255,255,255,0.02)",
overflow: "hidden",
marginBottom: 8,
};
const schemeHeaderBtnStyle: React.CSSProperties = {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 12px",
background: "transparent",
border: "none",
cursor: "pointer",
borderBottom: "1px solid rgba(255,255,255,0.05)",
};
const expandBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 0,
display: "flex",
alignItems: "center",
flexShrink: 0,
width: 18,
};
const chipStyle: React.CSSProperties = {
padding: "2px 8px",
borderRadius: 999,
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#8fa8c6",
fontSize: 11,
};
const iconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "none",
color: "#8fa8c6",
cursor: "pointer",
padding: 2,
display: "grid",
placeItems: "center",
};
const navLinkStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 5,
background: "transparent",
border: "none",
cursor: "pointer",
padding: "2px 0",
textAlign: "left",
};
const centerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
padding: 24,
};
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useState } from "react";
import {
BookMarked,
GitMerge,
HeartPulse,
Layers,
Shield,
Sliders,
} from "lucide-react";
import { OntologyManager } from "./OntologyManager";
export type OntologyHubTab =
| "registry"
| "editor"
| "versions"
| "alignments"
| "health"
| "shacl";
const TAB_PARAM = "ontologyTab";
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
{ id: "registry", label: "Registry", icon: BookMarked },
{ id: "editor", label: "Editor", icon: Sliders },
{ id: "versions", label: "Versions", icon: Layers },
{ id: "alignments", label: "Alignments", icon: GitMerge },
{ id: "health", label: "Health", icon: HeartPulse },
{ id: "shacl", label: "SHACL", icon: Shield },
];
function readTabParam(): OntologyHubTab {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get(TAB_PARAM);
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
} catch {
// ignore
}
return "registry";
}
function writeTabParam(tab: OntologyHubTab) {
try {
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, tab);
window.history.replaceState(null, "", `?${params.toString()}`);
} catch {
// ignore
}
}
function ComingSoonStub({
icon: Icon,
title,
description,
badge,
}: {
icon: typeof GitMerge;
title: string;
description: string;
badge: string;
}) {
return (
<div style={stubShellStyle}>
<div style={stubCardStyle}>
<div style={stubIconRingStyle}>
<Icon size={28} color="#7fd0ff" />
</div>
<div style={stubBadgeStyle}>{badge}</div>
<h2 style={stubTitleStyle}>{title}</h2>
<p style={stubDescStyle}>{description}</p>
<div style={stubDividerStyle} />
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
</div>
</div>
);
}
export function OntologyWorkspace() {
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
useEffect(() => {
writeTabParam(activeTab);
}, [activeTab]);
const handleTabChange = useCallback((tab: OntologyHubTab) => {
setActiveTab(tab);
}, []);
const renderTab = () => {
switch (activeTab) {
case "registry":
return <OntologyManager />;
case "editor":
return (
<ComingSoonStub
icon={Sliders}
title="Visual Ontology Editor"
description="Visually edit classes, properties, individuals, restrictions, axioms, and SKOS metadata. Create and propose schema changes through a governed draft workflow."
badge="Subissue 2"
/>
);
case "versions":
return (
<ComingSoonStub
icon={Layers}
title="Versions & Change Proposals"
description="View version history, compare schema diffs, submit change proposals, and manage the review-to-publish lifecycle."
badge="Subissue 2"
/>
);
case "alignments":
return (
<ComingSoonStub
icon={GitMerge}
title="Cross-Ontology Alignments"
description="Manage mappings between ontologies, review suggested alignments from embedding-assisted similarity, and publish alignment sets."
badge="Subissue 3"
/>
);
case "health":
return (
<ComingSoonStub
icon={HeartPulse}
title="Ontology Health Dashboard"
description="Score completeness, consistency, SHACL conformance, alignment coverage, and documentation quality across all loaded ontologies."
badge="Subissue 3"
/>
);
case "shacl":
return (
<ComingSoonStub
icon={Shield}
title="SHACL Studio"
description="Generate, edit, and validate SHACL shapes. Preview constraint violations against the active graph before publishing."
badge="Subissue 3"
/>
);
}
};
return (
<div style={shellStyle}>
<div style={tabBarStyle}>
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
style={{
...tabBtnBase,
...(activeTab === id ? tabBtnActive : tabBtnIdle),
}}
onClick={() => handleTabChange(id)}
>
<Icon size={14} />
<span>{label}</span>
</button>
))}
</div>
<div style={contentStyle}>{renderTab()}</div>
</div>
);
}
/* ─── styles ─────────────────────────────────────────────────────────── */
const shellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
background: "#07111f",
overflow: "hidden",
};
const tabBarStyle: React.CSSProperties = {
display: "flex",
gap: 6,
padding: "10px 18px",
borderBottom: "1px solid rgba(140,192,255,0.12)",
background: "rgba(3,9,18,0.72)",
flexShrink: 0,
flexWrap: "wrap",
};
const tabBtnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "7px 13px",
borderRadius: 999,
border: "1px solid transparent",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
transition: "160ms ease",
background: "transparent",
};
const tabBtnIdle: React.CSSProperties = {
color: "#8fa8c6",
borderColor: "rgba(127,208,255,0.1)",
};
const tabBtnActive: React.CSSProperties = {
color: "#ebf3ff",
background: "rgba(74,163,255,0.16)",
borderColor: "rgba(127,208,255,0.3)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
};
const contentStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const stubShellStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
};
const stubCardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
padding: "48px 52px",
borderRadius: 28,
border: "1px solid rgba(127,208,255,0.12)",
background: "rgba(9,19,34,0.82)",
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
maxWidth: 480,
textAlign: "center",
};
const stubIconRingStyle: React.CSSProperties = {
width: 64,
height: 64,
borderRadius: "50%",
display: "grid",
placeItems: "center",
background: "rgba(74,163,255,0.1)",
border: "1px solid rgba(127,208,255,0.18)",
marginBottom: 4,
};
const stubBadgeStyle: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 999,
background: "rgba(242,182,109,0.1)",
border: "1px solid rgba(242,182,109,0.22)",
color: "#f2b66d",
fontSize: 10,
fontWeight: 800,
letterSpacing: "0.1em",
textTransform: "uppercase",
};
const stubTitleStyle: React.CSSProperties = {
margin: 0,
color: "#ebf3ff",
fontSize: 22,
fontWeight: 800,
letterSpacing: "-0.04em",
};
const stubDescStyle: React.CSSProperties = {
margin: 0,
color: "#8fa8c6",
fontSize: 14,
lineHeight: 1.65,
maxWidth: 360,
};
const stubDividerStyle: React.CSSProperties = {
width: "100%",
height: 1,
background: "rgba(127,208,255,0.08)",
};
const stubSubnoteStyle: React.CSSProperties = {
margin: 0,
color: "#5a7a9a",
fontSize: 12,
};
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -91,7 +91,8 @@ claude --plugin-dir ./plugins
Or inside a session:
```bash
/plugin install ./plugins
/plugin marketplace add ./plugins
/plugin install semantica@semantica-local
```
Verify:
+4
View File
@@ -1,5 +1,9 @@
{
"name": "semantica-local",
"owner": {
"name": "Hawksight AI",
"url": "https://github.com/Hawksight-AI/semantica"
},
"plugins": [
{
"name": "semantica",
+1 -2
View File
@@ -25,6 +25,5 @@
"mcp"
],
"skills": "./skills",
"agents": "./agents",
"hooks": "./hooks/hooks.json"
"agents": "./agents"
}
+1 -1
View File
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.18.0"]
llm-ollama = ["ollama>=0.1.0"]
llm-deepseek = ["deepseek>=0.1.0"]
llm-deepseek = ["openai>=1.0.0"]
llm-litellm = ["litellm>=1.0.0"]
llm-instructor = ["instructor>=1.0.0"]
+2 -2
View File
@@ -1,8 +1,8 @@
mkdocs>=1.5.0
mkdocs-material>=9.4.0
mkdocs-material>=9.7.6
mkdocs-minify-plugin>=0.7.0
mkdocs-mermaid2-plugin>=1.0.0
pymdown-extensions>=10.0
pymdown-extensions>=10.21.2
mkdocstrings[python]>=0.24.0
mkdocs-jupyter>=0.24.0
+206 -6
View File
@@ -74,6 +74,7 @@ Production Use Cases:
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger
from .agent_memory import AgentMemory
from .context_retriever import ContextRetriever, RetrievedContext
@@ -507,6 +508,10 @@ class AgentContext:
include_relationships: bool = False,
expand_graph: bool = True,
deduplicate: bool = True,
anchor_node: Optional[str] = None,
max_hops: Optional[int] = None,
proximity_weight: float = 0.0,
min_confidence_decay: float = 0.0,
**kwargs,
) -> List[Dict[str, Any]]:
"""
@@ -560,17 +565,33 @@ class AgentContext:
**kwargs,
)
# Convert RetrievedContext to dicts
return [
result_dicts = [
self._context_to_dict(r, include_entities, include_relationships)
for r in results
]
return self._apply_proximity_metadata(
result_dicts,
anchor_node=anchor_node,
max_hops=max_hops,
proximity_weight=proximity_weight,
min_confidence_decay=min_confidence_decay,
max_results=max_results,
)
else:
# Simple RAG: Use AgentMemory (vector + memory)
results = self._memory.retrieve(
query, max_results=max_results, min_score=min_score, **kwargs
)
# Convert to dicts
return [self._memory_to_dict(r) for r in results]
result_dicts = [self._memory_to_dict(r) for r in results]
return self._apply_proximity_metadata(
result_dicts,
anchor_node=anchor_node,
max_hops=max_hops,
proximity_weight=proximity_weight,
min_confidence_decay=min_confidence_decay,
max_results=max_results,
)
def query_with_reasoning(
self,
@@ -814,6 +835,77 @@ class AgentContext:
return result
def _apply_proximity_metadata(
self,
results: List[Dict[str, Any]],
anchor_node: Optional[str] = None,
max_hops: Optional[int] = None,
proximity_weight: float = 0.0,
min_confidence_decay: float = 0.0,
max_results: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Enrich retrieval results with graph distance from an anchor node."""
if not anchor_node or not self.knowledge_graph:
return results
if not hasattr(self.knowledge_graph, "get_neighbor_distances"):
return results
search_hops = max_hops if max_hops is not None else 10
distances = self.knowledge_graph.get_neighbor_distances(
anchor_node,
hops=search_hops,
min_confidence=min_confidence_decay,
)
by_node_id = {item.get("id"): item for item in distances}
if anchor_node:
by_node_id[anchor_node] = {
"id": anchor_node,
"hop": 0,
"confidence_decay": 1.0,
"distance_band": "direct",
"path_to_anchor": [anchor_node],
}
enriched: List[Dict[str, Any]] = []
for result in results:
metadata = result.get("metadata") or {}
result_id = (
result.get("id")
or metadata.get("node_id")
or metadata.get("id")
or metadata.get("memory_id")
)
distance = by_node_id.get(result_id)
if not distance:
if max_hops is not None or min_confidence_decay > 0.0:
continue
enriched.append(result)
continue
hop_distance = distance.get("hop")
if max_hops is not None and hop_distance is not None and hop_distance > max_hops:
continue
proximity_score = 1.0 if hop_distance == 0 else 1.0 / float(hop_distance or 1)
score = float(result.get("score", 0.0))
bounded_weight = min(max(float(proximity_weight), 0.0), 1.0)
combined_score = (1.0 - bounded_weight) * score + bounded_weight * proximity_score
enriched_result = {
**result,
"graph_node_id": result_id,
"hop_distance": hop_distance,
"confidence_decay": distance.get("confidence_decay"),
"distance_band": distance.get("distance_band"),
"path_to_anchor": distance.get("path_to_anchor"),
"proximity_score": proximity_score,
"combined_score": combined_score,
}
enriched.append(enriched_result)
if proximity_weight > 0:
enriched.sort(key=lambda item: item.get("combined_score", item.get("score", 0.0)), reverse=True)
return enriched[:max_results] if max_results is not None else enriched
def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
"""Convert memory result to dict."""
return {
@@ -2228,7 +2320,10 @@ class AgentContext:
category: Optional[str] = None,
limit: int = 10,
use_kg_features: bool = True,
similarity_weights: Optional[Dict[str, float]] = None
similarity_weights: Optional[Dict[str, float]] = None,
anchor_decision_id: Optional[str] = None,
max_causal_hops: Optional[int] = None,
min_confidence_decay: float = 0.0,
) -> List[Decision]:
"""
Find precedents using advanced KG and vector store features.
@@ -2248,7 +2343,7 @@ class AgentContext:
try:
if hasattr(self._decision_query, 'find_precedents_hybrid'):
return self._decision_query.find_precedents_hybrid(
precedents = self._decision_query.find_precedents_hybrid(
scenario=scenario,
category=category,
limit=limit,
@@ -2257,11 +2352,116 @@ class AgentContext:
)
else:
# Fallback to basic method
return self.find_precedents(scenario, category, limit)
precedents = self.find_precedents(scenario, category, limit)
return self._apply_causal_proximity_to_precedents(
precedents,
anchor_decision_id=anchor_decision_id,
max_causal_hops=max_causal_hops,
min_confidence_decay=min_confidence_decay,
limit=limit,
)
except Exception as e:
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
return []
def _apply_causal_proximity_to_precedents(
self,
precedents: List[Decision],
anchor_decision_id: Optional[str] = None,
max_causal_hops: Optional[int] = None,
min_confidence_decay: float = 0.0,
limit: int = 10,
) -> List[Decision]:
"""Attach causal-distance metadata to precedents and optionally filter."""
if not anchor_decision_id or not self.knowledge_graph:
return precedents
causal_types = ["causes", "influences", "leads_to", "supports"]
max_hops = max_causal_hops if max_causal_hops is not None else 10
distance_by_id: Dict[str, Dict[str, Any]] = {}
if hasattr(self.knowledge_graph, "get_neighbor_distances"):
for item in self.knowledge_graph.get_neighbor_distances(
anchor_decision_id,
hops=max_hops,
relationship_types=causal_types,
min_confidence=min_confidence_decay,
):
distance_by_id[item.get("id")] = item
annotated: List[Decision] = []
for decision in precedents:
decision_id = getattr(decision, "decision_id", None)
distance = distance_by_id.get(decision_id)
if distance is None and hasattr(self.knowledge_graph, "trace_decision_causality"):
distance = self._distance_from_causality_trace(anchor_decision_id, decision_id, max_hops)
if distance is None:
if max_causal_hops is not None or min_confidence_decay > 0.0:
continue
setattr(decision, "causal_hop_distance", None)
setattr(decision, "path_confidence_decay", None)
setattr(decision, "distance_band", None)
annotated.append(decision)
continue
hop_distance = distance.get("hop", distance.get("hop_count"))
confidence_decay = distance.get("confidence_decay")
if max_causal_hops is not None and hop_distance is not None and hop_distance > max_causal_hops:
continue
if confidence_decay is not None and confidence_decay < min_confidence_decay:
continue
setattr(decision, "causal_hop_distance", hop_distance)
setattr(decision, "path_confidence_decay", confidence_decay)
setattr(decision, "distance_band", distance.get("distance_band"))
annotated.append(decision)
annotated.sort(
key=lambda decision: (
getattr(decision, "causal_hop_distance", None) is None,
getattr(decision, "causal_hop_distance", 10**9) or 10**9,
-(getattr(decision, "path_confidence_decay", 0.0) or 0.0),
)
)
return annotated[:limit]
def _distance_from_causality_trace(
self,
anchor_decision_id: str,
target_decision_id: Optional[str],
max_hops: int,
) -> Optional[Dict[str, Any]]:
"""Infer anchor-to-target distance from ContextGraph causality reports."""
if not target_decision_id:
return None
try:
chains = self.knowledge_graph.trace_decision_causality(target_decision_id, max_depth=max_hops)
except Exception:
return None
best: Optional[Dict[str, Any]] = None
for chain in chains:
hops = chain.get("hops", chain) if isinstance(chain, dict) else chain
if not hops:
continue
starts_at_anchor = hops[0].get("from") == anchor_decision_id
ends_at_target = hops[-1].get("to") == target_decision_id
if starts_at_anchor and ends_at_target:
candidate = {
"hop_count": len(hops),
"confidence_decay": chain.get("confidence_decay") if isinstance(chain, dict) else None,
"distance_band": chain.get("distance_band") if isinstance(chain, dict) else None,
}
if candidate["confidence_decay"] is None:
decay = 1.0
for hop in hops:
decay *= float(hop.get("edge_weight", 1.0))
candidate["confidence_decay"] = decay
if candidate["distance_band"] is None:
candidate["distance_band"] = classify_path_distance(candidate["hop_count"])
if best is None or candidate["hop_count"] < best["hop_count"]:
best = candidate
return best
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
"""
Analyze decision influence using advanced graph algorithms.
+100
View File
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Set
from collections import deque
from ..graph_store import GraphStore
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger
from .decision_models import Decision
@@ -677,3 +678,102 @@ class CausalChainAnalyzer:
else:
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
return decisions
def interpret_causal_distance(
self,
source_id: str,
target_id: str,
) -> Dict[str, Any]:
"""
Traverse only causal-typed edges and return a structured distance report.
Returns a dict matching CausalDistanceReport with keys:
source_id, target_id, causal_path, causal_hop_count,
intermediate_decisions, confidence_decay, weakest_link, interpretation
"""
from collections import deque as _deque
CAUSAL_TYPES = {"causes", "influences", "leads_to", "supports",
"CAUSED", "INFLUENCED", "PRECEDENT_FOR"}
graph = self.graph_store
# ContextGraph-native BFS over causal edges
if hasattr(graph, "nodes") and hasattr(graph, "_adjacency"):
if source_id not in graph.nodes:
return self._unreachable_report(source_id, target_id)
queue = _deque([(source_id, [source_id], 1.0, None)])
visited: Set[str] = {source_id}
while queue:
current_id, path, decay, weakest = queue.popleft()
if current_id == target_id:
hop_count = len(path) - 1
intermediates = [
n for n in path[1:-1]
if str(getattr(graph.nodes.get(n), "node_type", "")).lower() == "decision"
]
band = classify_path_distance(hop_count)
interp = self._causal_interpretation(hop_count, decay, band)
return {
"source_id": source_id,
"target_id": target_id,
"causal_path": path,
"causal_hop_count": hop_count,
"intermediate_decisions": intermediates,
"confidence_decay": round(decay, 6),
"weakest_link": weakest,
"interpretation": interp,
}
with graph._lock:
outgoing = list(graph._adjacency.get(current_id, []))
for edge in outgoing:
if edge.edge_type not in CAUSAL_TYPES:
continue
nxt = edge.target_id
if nxt in visited:
continue
visited.add(nxt)
new_decay = decay * edge.weight
new_weakest = weakest
if weakest is None or edge.weight < weakest.get("edge_weight", 1.0):
new_weakest = {"source": current_id, "target": nxt, "edge_weight": edge.weight}
queue.append((nxt, path + [nxt], new_decay, new_weakest))
return self._unreachable_report(source_id, target_id)
# GraphStore fallback — return not-reachable; callers can use get_causal_chain instead
return self._unreachable_report(source_id, target_id)
@staticmethod
def _causal_interpretation(hop_count: int, decay: float, band: str) -> str:
if band == "direct":
base = f"Direct cause with confidence {decay:.2f}."
elif band == "near":
base = (
f"Mediated through {hop_count - 1} decision(s); "
f"confidence decays to {decay:.2f}"
)
base += " — moderate evidence." if decay > 0.4 else " — weak evidence."
else:
base = (
f"Distal influence across {hop_count} causal steps; "
f"confidence near {decay:.2f} — weak signal."
)
return base
@staticmethod
def _unreachable_report(source_id: str, target_id: str) -> Dict[str, Any]:
return {
"source_id": source_id,
"target_id": target_id,
"causal_path": [],
"causal_hop_count": 0,
"intermediate_decisions": [],
"confidence_decay": 0.0,
"weakest_link": None,
"interpretation": "No causal path found between the two nodes.",
}
+207 -24
View File
@@ -116,6 +116,7 @@ import uuid
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from ..utils.helpers import classify_path_distance
from .entity_linker import EntityLinker
# Optional imports for advanced features
@@ -130,6 +131,13 @@ except ImportError:
KG_AVAILABLE = False
class _CausalChain(dict):
"""Dict response that still iterates over hops for legacy callers."""
def __iter__(self):
return iter(self.get("hops", []))
def _parse_iso_dt(value: str) -> Optional[datetime]:
"""Parse an ISO datetime string into a tz-naive UTC datetime.
@@ -739,6 +747,7 @@ class ContextGraph:
min_weight: float = 0.0,
skip: int = 0,
limit: Optional[int] = None,
include_distance_metadata: bool = False,
) -> List[Dict[str, Any]]:
"""
Get neighbors of a node.
@@ -762,11 +771,11 @@ class ContextGraph:
neighbors: List[Dict[str, Any]] = []
visited = {node_id}
queue = deque([(node_id, 0)])
queue = deque([(node_id, 0, [node_id], 1.0)])
rel_filter = set(relationship_types) if relationship_types else None
while queue:
current_id, current_hop = queue.popleft()
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
if current_hop >= hops:
continue
@@ -780,26 +789,59 @@ class ContextGraph:
if neighbor_id in visited:
continue
visited.add(neighbor_id)
queue.append((neighbor_id, current_hop + 1))
next_hop = current_hop + 1
next_decay = decay_so_far * edge.weight
next_path = path_so_far + [neighbor_id]
queue.append((neighbor_id, next_hop, next_path, next_decay))
node = self.nodes.get(neighbor_id)
if not node:
continue
neighbors.append(
{
"id": node.node_id,
"type": node.node_type,
"content": node.content,
"relationship": edge.edge_type,
"weight": edge.weight,
"hop": current_hop + 1,
}
)
entry: Dict[str, Any] = {
"id": node.node_id,
"type": node.node_type,
"content": node.content,
"relationship": edge.edge_type,
"weight": edge.weight,
"hop": next_hop,
}
if include_distance_metadata:
entry["distance_band"] = classify_path_distance(next_hop)
entry["confidence_decay"] = next_decay
entry["path_to_anchor"] = next_path
neighbors.append(entry)
if limit is not None:
return neighbors[skip: skip + limit]
return neighbors[skip:]
def get_neighbor_distances(
self,
node_id: str,
hops: int = 3,
relationship_types: Optional[List[str]] = None,
min_confidence: float = 0.0,
) -> List[Dict[str, Any]]:
"""
Return neighbors with distance metadata, filtered by confidence decay.
Results are ordered by nearest hop first, then by strongest path confidence.
"""
neighbors = self.get_neighbors(
node_id,
hops=hops,
relationship_types=relationship_types,
include_distance_metadata=True,
)
filtered = [
item for item in neighbors
if item.get("confidence_decay", 0.0) >= min_confidence
]
return sorted(
filtered,
key=lambda item: (item.get("hop", 0), -item.get("confidence_decay", 0.0)),
)
def query(
self, query: str, skip: int = 0, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
@@ -1187,6 +1229,90 @@ class ContextGraph:
other_graph, _, target_node_id = self._linked_graphs[link_id]
return other_graph, target_node_id
def cross_graph_path(
self,
source_node_id: str,
target_graph: "ContextGraph",
target_node_id: str,
max_hops: int = 10,
) -> Dict[str, Any]:
"""
Find the shortest path across linked ContextGraph instances.
"""
start = (self.graph_id, source_node_id)
goal = (target_graph.graph_id, target_node_id)
if source_node_id not in self.nodes or target_node_id not in target_graph.nodes:
return {
"path": [],
"hop_count": 0,
"cross_graph_links_used": 0,
"confidence_decay": 0.0,
"distance_band": classify_path_distance(max_hops + 1),
"reachable": False,
}
queue = deque([(self, source_node_id, [start], 0, 1.0, 0)])
visited = {start}
while queue:
graph, current_id, path, hop_count, decay, links_used = queue.popleft()
current_key = (graph.graph_id, current_id)
if current_key == goal:
return {
"path": path,
"hop_count": hop_count,
"cross_graph_links_used": links_used,
"confidence_decay": decay,
"distance_band": classify_path_distance(hop_count),
"reachable": True,
}
if hop_count >= max_hops:
continue
with graph._lock:
outgoing_edges = list(graph._adjacency.get(current_id, []))
for edge in outgoing_edges:
marker = graph.nodes.get(edge.target_id)
link_id = None
if marker and marker.node_type == "cross_graph_link":
link_id = marker.metadata.get("link_id")
if link_id:
try:
next_graph, next_node_id = graph.navigate_to(link_id)
except KeyError:
continue
next_key = (next_graph.graph_id, next_node_id)
next_links_used = links_used + 1
else:
next_graph, next_node_id = graph, edge.target_id
next_key = (graph.graph_id, edge.target_id)
next_links_used = links_used
if next_key in visited:
continue
visited.add(next_key)
queue.append(
(
next_graph,
next_node_id,
path + [next_key],
hop_count + 1,
decay * edge.weight,
next_links_used,
)
)
return {
"path": [],
"hop_count": 0,
"cross_graph_links_used": 0,
"confidence_decay": 0.0,
"distance_band": classify_path_distance(max_hops + 1),
"reachable": False,
}
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
"""
Reconnect cross-graph links after a :meth:`load_from_file` call.
@@ -2552,13 +2678,14 @@ class ContextGraph:
# Calculate influence scores
influence_scores = {}
for influenced_id in direct_influence | indirect_influence:
score = self._calculate_decision_influence_score(decision_id, influenced_id)
influence_scores[influenced_id] = score
influence_scores[influenced_id] = self._calculate_decision_influence_score(
decision_id, influenced_id
)
# Sort by influence score
sorted_influence = sorted(
influence_scores.items(),
key=lambda x: x[1],
key=lambda x: x[1].get("score", 0.0),
reverse=True
)
@@ -2576,11 +2703,22 @@ class ContextGraph:
"direct_influence": [_enrich(did) for did in direct_influence],
"indirect_influence": [_enrich(did) for did in indirect_influence],
"influence_scores": [
{**_enrich(did), "score": score}
for did, score in sorted_influence
{
**_enrich(did),
"score": details.get("score", 0.0),
"score_breakdown": {
"entity_overlap": details.get("entity_score", 0.0),
"category_match": details.get("category_score", 0.0),
"temporal_proximity": details.get("time_score", 0.0),
},
"is_direct": did in direct_influence,
}
for did, details in sorted_influence
],
"total_influenced": len(influence_scores),
"max_influence_score": max(influence_scores.values()) if influence_scores else 0.0
"max_influence_score": max(
details.get("score", 0.0) for details in influence_scores.values()
) if influence_scores else 0.0
}
def get_decision_insights(self) -> Dict[str, Any]:
@@ -2677,15 +2815,17 @@ class ContextGraph:
for cause_id in potential_causes:
cause_dec = self._decisions.get(cause_id, {})
edge_weight = float(cause_dec.get("confidence", 1.0))
hop = {
"from": cause_id,
"from_scenario": cause_dec.get("scenario", ""),
"to": current_id,
"to_scenario": current_decision.get("scenario", ""),
"type": "influences",
"edge_weight": edge_weight,
}
cause_path = path + [hop]
causal_chain.append(cause_path)
causal_chain.append(self._build_causal_chain_report(list(reversed(cause_path))))
trace_recursive(cause_id, depth + 1, cause_path)
trace_recursive(decision_id, 0, [])
@@ -2942,11 +3082,49 @@ class ContextGraph:
self.logger.warning(f"Indirect influence analysis failed: {e}")
return set()
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> float:
def _build_causal_chain_report(self, hops: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Build an auditable causal-chain response from hop records."""
hop_count = len(hops)
confidence_decay = 1.0
weakest_link = None
for hop in hops:
edge_weight = float(hop.get("edge_weight", 1.0))
confidence_decay *= edge_weight
if weakest_link is None or edge_weight < float(weakest_link.get("edge_weight", 1.0)):
weakest_link = hop
if hop_count <= 1:
interpretation = f"Direct influence with confidence {confidence_decay:.2f}."
elif confidence_decay > 0.7:
interpretation = (
f"Mediated through {hop_count - 1} step(s) with high confidence "
f"({confidence_decay:.2f})."
)
elif confidence_decay > 0.4:
interpretation = (
f"Mediated through {hop_count - 1} step(s) - confidence decays "
f"to {confidence_decay:.2f}."
)
else:
interpretation = (
f"Distal influence across {hop_count} causal steps; confidence "
f"{confidence_decay:.2f} is weak evidence."
)
return _CausalChain({
"hops": hops,
"hop_count": hop_count,
"confidence_decay": confidence_decay,
"weakest_link": weakest_link,
"distance_band": classify_path_distance(hop_count),
"interpretation": interpretation,
})
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> Dict[str, float]:
"""Calculate influence score between two decisions."""
try:
if not hasattr(self, '_decisions'):
return 0.0
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
source_decision = self._decisions[source_id]
target_decision = self._decisions[target_id]
@@ -2965,11 +3143,16 @@ class ContextGraph:
# Combined score
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_score
return combined_score
return {
"score": combined_score,
"entity_score": entity_score,
"category_score": category_score,
"time_score": time_score,
}
except Exception as e:
self.logger.warning(f"Influence score calculation failed: {e}")
return 0.0
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
"""Get temporal analysis of decisions."""
+18
View File
@@ -19,7 +19,12 @@ from .ws import ConnectionManager
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
previous_callback = getattr(session.graph, "mutation_callback", None)
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
session.handle_graph_mutation(event_type, entity_id, payload)
if callable(previous_callback):
previous_callback(event_type, entity_id, payload)
loop = getattr(app.state, "event_loop", None)
manager = getattr(app.state, "ws_manager", None)
if loop is None or manager is None or loop.is_closed():
@@ -93,6 +98,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
from .routes.enrich import router as enrich_router
from .routes.export_import import router as export_import_router
from .routes.graph import router as graph_router
from .routes.ontology import router as ontology_router
from .routes.provenance import router as provenance_router
from .routes.sparql import router as sparql_router
from .routes.temporal import router as temporal_router
@@ -108,6 +114,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
app.include_router(sparql_router)
app.include_router(provenance_router)
app.include_router(vocabulary_router)
app.include_router(ontology_router)
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
@@ -150,6 +157,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
"status": "active",
}
@app.get("/", include_in_schema=False)
async def root():
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
if index_path.is_file():
return FileResponse(index_path)
return HTMLResponse(
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
'<title>Semantica Knowledge Explorer</title></head>'
'<body><div id="root"></div></body></html>'
)
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
assets_dir = static_dir / "assets"
+15 -1
View File
@@ -8,7 +8,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from ..dependencies import get_session
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
from ..session import GraphSession
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
@@ -125,6 +125,20 @@ async def get_precedents(
return [_node_to_decision(decision) for _, decision in scored[:limit]]
@router.get("/causal-distance", response_model=CausalDistanceReport)
async def causal_distance(
source: str = Query(..., description="Source node/decision ID"),
target: str = Query(..., description="Target node/decision ID"),
session: GraphSession = Depends(get_session),
):
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
from ...context.causal_analyzer import CausalChainAnalyzer
analyzer = CausalChainAnalyzer(session.graph)
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
return CausalDistanceReport(**report)
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
async def check_compliance(
decision_id: str,
+5 -3
View File
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
continue
source, target = args
if session.get_node(source) is None:
session.graph.add_node(source, "entity", content=source)
session.add_node(source, "entity", content=source)
if session.get_node(target) is None:
session.graph.add_node(target, "entity", content=target)
session.add_node(target, "entity", content=target)
edge_type = body.inferred_edge_type or predicate
session.graph.add_edge(
session.add_edge(
source,
target,
edge_type=edge_type,
@@ -354,4 +354,6 @@ async def merge_nodes(
return removed, edges_updated
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
if removed_ids:
await asyncio.to_thread(session.rebuild_search_index)
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
+56 -1
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import Response
from ..dependencies import get_session
from ..schemas import ExportRequest, ImportResponse
from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
from ..session import GraphSession
logger = logging.getLogger(__name__)
@@ -236,3 +236,58 @@ async def export_graph(
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
)
_DISTANCE_EXPORT_MAX_NODES = 200
@router.post("/api/export/distance-enriched")
async def export_distance_enriched(
body: DistanceExportRequest,
session: GraphSession = Depends(get_session),
):
"""FR-10 — Export pairwise distance metrics as CSV or JSONL for ML pipelines."""
if not body.node_subset:
raise HTTPException(
status_code=422,
detail=(
f"node_subset is required; provide up to {_DISTANCE_EXPORT_MAX_NODES} node IDs to export."
),
)
if len(body.node_subset) > _DISTANCE_EXPORT_MAX_NODES:
raise HTTPException(
status_code=413,
detail=(
f"node_subset exceeds limit: {len(body.node_subset)} nodes requested; "
f"maximum is {_DISTANCE_EXPORT_MAX_NODES}."
),
)
import asyncio
from ...export.distance_exporter import DistanceExporter
exporter = DistanceExporter(session.graph)
if body.format == "csv":
content = await asyncio.to_thread(
exporter.to_csv_string,
include=body.include,
node_subset=body.node_subset,
)
return Response(
content=content,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="distances.csv"'},
)
else:
content = await asyncio.to_thread(
exporter.to_jsonl_string,
include=body.include,
node_subset=body.node_subset,
)
return Response(
content=content,
media_type="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="distances.jsonl"'},
)
+457 -15
View File
@@ -3,13 +3,20 @@ Graph routes for explorer node, edge, path, and search APIs.
"""
import asyncio
import logging
import time
from enum import Enum
from typing import Optional
from typing import List, Optional
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query
from ...utils.helpers import classify_path_distance
from ..dependencies import get_session
from ..schemas import (
DistanceMatrixRequest,
DistanceMatrixResponse,
EdgeListResponse,
EdgeResponse,
GraphStatsResponse,
@@ -20,12 +27,45 @@ from ..schemas import (
SearchRequest,
SearchResultItem,
SearchResultResponse,
SemanticNeighborItem,
SemanticNeighborhoodResponse,
)
from ..session import GraphSession
router = APIRouter(prefix="/api/graph", tags=["Graph"])
def _build_interpretation(
distance_band: str,
hop_count: int,
bottleneck_node: Optional[str],
confidence_decay: Optional[float],
) -> str:
if distance_band == "direct":
base = "Direct relationship"
elif distance_band == "near":
base = f"Closely related via {hop_count - 1} intermediate node(s)"
elif distance_band == "mid-range":
base = f"Reachable in {hop_count} steps across topic boundaries"
else:
base = f"Distal connection spanning {hop_count} hops"
if bottleneck_node:
base += f", routed through bottleneck '{bottleneck_node}'"
if confidence_decay is not None:
if confidence_decay > 0.7:
base += " — high confidence."
elif confidence_decay > 0.4:
base += " — moderate confidence."
else:
base += " — low confidence, treat as weak evidence."
else:
base += "."
return base
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
if not raw_bbox:
return None
@@ -38,6 +78,66 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
return min_x, min_y, max_x, max_y
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
if isinstance(value, dict):
# Probe keys in priority order: generic first, then framework-specific.
# Must stay aligned with the top-level keys in _extract_node_embeddings.
for key in ("embedding", "embeddings", "vector", "values", "node2vec", "semantic"):
nested = _coerce_embedding_vector(value.get(key))
if nested is not None:
return nested
return None
if not isinstance(value, (list, tuple)):
return None
vector: List[float] = []
for item in value:
try:
vector.append(float(item))
except (TypeError, ValueError):
return None
return vector if vector else None
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
# Top-level keys to probe on each entity (and its metadata/properties dicts).
# Priority: generic names first, then KG-extras-specific names.
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
# TODO: cache this per-session graph revision to avoid re-scanning all nodes on every request.
embedding_keys = (
"embedding",
"embeddings",
"vector",
"node_embedding",
"node2vec_embedding",
"semantic_embedding",
"reasoning_embedding",
)
embeddings: dict[str, List[float]] = {}
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
if not isinstance(entity, dict):
continue
node_id = entity.get("id") or entity.get("node_id")
if not node_id:
continue
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
for key in embedding_keys:
vector = _coerce_embedding_vector(
entity.get(key, metadata.get(key, properties.get(key)))
)
if vector is not None:
embeddings[str(node_id)] = vector
break
return embeddings
def _node_response(node: dict) -> NodeResponse:
return NodeResponse(**node)
@@ -141,13 +241,16 @@ class _PathAlgorithm(str, Enum):
dijkstra = "dijkstra"
@router.get("/node/{node_id}/path", response_model=PathResponse)
async def find_path(
node_id: str,
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
session: GraphSession = Depends(get_session),
):
async def _find_path_impl(
source: str,
target: str,
algorithm: _PathAlgorithm,
directed: bool,
session: GraphSession,
) -> PathResponse:
"""Resolve and enrich a path between two arbitrary graph node ids."""
path_finder = session.path_finder
if path_finder is None:
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
@@ -159,37 +262,376 @@ async def find_path(
else path_finder.bfs_shortest_path
)
try:
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
result = await asyncio.to_thread(path_fn, graph_dict, source, target, directed=directed)
except Exception as exc:
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}': {exc}")
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
if not path_nodes:
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}'")
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
hop_count = len(path_nodes) - 1 if path_nodes else 0
distance_band = classify_path_distance(hop_count)
# FR-4 enrichment — compute optional fields from existing session analytics
confidence_decay: Optional[float] = None
bottleneck_node: Optional[str] = None
semantic_similarity: Optional[float] = None
path_coherence_score: Optional[float] = None
alternative_path_count: int = 0
try:
graph_dict = await asyncio.to_thread(session.build_graph_dict)
# Build edge weight index once in O(E) so each hop lookup is O(1).
# graph_dict may use "edges" or "relationships" depending on the graph source.
edge_weight_index: dict = {}
for _e in graph_dict.get("edges") or graph_dict.get("relationships", []):
_s, _t = _e.get("source"), _e.get("target")
_w = float(_e.get("weight", 1.0))
edge_weight_index[(_s, _t)] = _w
if not directed:
edge_weight_index.setdefault((_t, _s), _w)
# Confidence decay — product of edge weights along the path (O(L))
decay = 1.0
for i in range(len(path_nodes) - 1):
decay *= edge_weight_index.get((path_nodes[i], path_nodes[i + 1]), 1.0)
confidence_decay = decay
# Bottleneck — intermediate node with highest betweenness in subgraph
intermediates = path_nodes[1:-1] if len(path_nodes) > 2 else []
if intermediates and session.centrality is not None:
sub_dict = await asyncio.to_thread(session.build_graph_dict, path_nodes)
centrality_result = await asyncio.to_thread(
session.centrality.calculate_betweenness_centrality, sub_dict
)
scores = centrality_result.get("betweenness", {}) if isinstance(centrality_result, dict) else {}
if scores:
bottleneck_node = max(
(n for n in intermediates if n in scores),
key=lambda n: scores.get(n, 0.0),
default=None,
)
# Alternative paths — count simple paths within hop_count + 2
if path_finder is not None and hop_count > 0:
try:
k_paths = await asyncio.to_thread(
path_finder.find_k_shortest_paths,
graph_dict, source, target, hop_count + 2, directed=directed
)
alternative_path_count = max(0, len(k_paths) - 1)
except Exception as exc:
logger.debug("k_shortest_paths unavailable for enrichment: %s", exc)
# Semantic similarity (source ↔ target)
if session.similarity is not None:
try:
sim_result = await asyncio.to_thread(
session.similarity.cosine_similarity,
graph_dict, source, target
)
if isinstance(sim_result, (int, float)):
semantic_similarity = float(sim_result)
except Exception as exc:
logger.debug("semantic_similarity unavailable for enrichment: %s", exc)
# Path coherence — mean pairwise similarity of consecutive nodes
if session.similarity is not None and len(path_nodes) >= 2:
try:
pair_sims: List[float] = []
for i in range(len(path_nodes) - 1):
sim = await asyncio.to_thread(
session.similarity.cosine_similarity,
graph_dict, path_nodes[i], path_nodes[i + 1]
)
if isinstance(sim, (int, float)):
pair_sims.append(float(sim))
if pair_sims:
path_coherence_score = sum(pair_sims) / len(pair_sims)
except Exception as exc:
logger.debug("path_coherence unavailable for enrichment: %s", exc)
except Exception as exc:
logger.debug("FR-4 enrichment skipped: %s", exc)
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
return PathResponse(
source=node_id,
source=source,
target=target,
algorithm=algorithm.value,
path=path_nodes,
edge_ids=edge_ids,
total_weight=total_weight,
directed=directed,
hop_count=hop_count,
distance_band=distance_band,
semantic_similarity=semantic_similarity,
path_coherence_score=path_coherence_score,
confidence_decay=confidence_decay,
bottleneck_node=bottleneck_node,
alternative_path_count=alternative_path_count,
interpretation=interpretation,
)
@router.get("/path", response_model=PathResponse)
async def find_path_by_query(
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
return await _find_path_impl(source, target, algorithm, directed, session)
@router.get("/node/{node_id}/path", response_model=PathResponse)
async def find_path(
node_id: str,
target: str = Query(..., description="Target node ID"),
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
session: GraphSession = Depends(get_session),
):
"""Deprecated path-segment route kept for backward compatibility.
Node IDs that contain slashes will return 404 because FastAPI decodes
%2F before route matching. Use GET /api/graph/path?source=...&target=...
for slash-safe path lookup.
"""
return await _find_path_impl(node_id, target, algorithm, directed, session)
@router.post("/search", response_model=SearchResultResponse)
async def search_nodes(
body: SearchRequest,
session: GraphSession = Depends(get_session),
):
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
items = [
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
for result in results
]
# FR-7 — compute hop distances from anchor when requested
hop_by_id: dict = {}
if body.anchor_node:
neighbors = await asyncio.to_thread(
session.graph.get_neighbor_distances,
body.anchor_node,
hops=body.max_hops if body.max_hops is not None else 10,
)
hop_by_id = {n.get("id"): n.get("hop") for n in neighbors}
hop_by_id[body.anchor_node] = 0
items: List[SearchResultItem] = []
for result in results:
node_data = result.get("node", {})
node_id = node_data.get("id", "")
raw_score = result.get("score", 0.0)
hop_distance: Optional[int] = hop_by_id.get(node_id) if body.anchor_node else None
# Drop results beyond max_hops
if body.anchor_node and body.max_hops is not None:
if hop_distance is None or hop_distance > body.max_hops:
continue
# Compute combined ranking score
final_score = raw_score
if body.anchor_node and hop_distance is not None:
proximity = 1.0 if hop_distance == 0 else 1.0 / hop_distance
if body.rank_by == "proximity":
final_score = proximity
elif body.rank_by == "hybrid":
final_score = 0.6 * raw_score + 0.4 * proximity
items.append(
SearchResultItem(
node=_node_response(node_data),
score=final_score,
hop_distance=hop_distance,
)
)
if body.rank_by in ("proximity", "hybrid") and body.anchor_node:
items.sort(key=lambda item: item.score, reverse=True)
return SearchResultResponse(results=items, total=len(items), query=body.query)
@router.post("/distance-matrix", response_model=DistanceMatrixResponse)
async def distance_matrix(
body: DistanceMatrixRequest,
session: GraphSession = Depends(get_session),
):
if len(body.node_ids) > 50:
raise HTTPException(
status_code=413,
detail=f"Too many nodes: {len(body.node_ids)} requested; maximum is 50 per request.",
)
if body.metric == "semantic" and session.similarity is None:
raise HTTPException(
status_code=503,
detail="metric='semantic' requires an embedding backend which is not available in this session.",
)
started = time.perf_counter()
graph_dict = await asyncio.to_thread(session.build_graph_dict)
path_finder = session.path_finder
n = len(body.node_ids)
matrix: List[List[Optional[float]]] = [[None] * n for _ in range(n)]
unreachable: List[tuple] = []
for i in range(n):
matrix[i][i] = 0.0
for j in range(i + 1, n):
src, tgt = body.node_ids[i], body.node_ids[j]
try:
if body.metric == "semantic" and session.similarity is not None:
sim = await asyncio.to_thread(
session.similarity.cosine_similarity, graph_dict, src, tgt
)
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
matrix[i][j] = val
matrix[j][i] = val
elif path_finder is not None:
path_fn = (
path_finder.dijkstra_shortest_path
if body.metric == "weighted"
else path_finder.bfs_shortest_path
)
result = await asyncio.to_thread(path_fn, graph_dict, src, tgt)
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
if path_nodes:
val = (
float(result.get("total_weight", len(path_nodes) - 1))
if body.metric == "weighted"
else float(len(path_nodes) - 1)
)
matrix[i][j] = val
matrix[j][i] = val
else:
unreachable.append((src, tgt))
unreachable.append((tgt, src))
except Exception as exc:
logger.debug("distance_matrix pair (%s, %s) failed: %s", src, tgt, exc)
unreachable.append((src, tgt))
unreachable.append((tgt, src))
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
return DistanceMatrixResponse(
nodes=body.node_ids,
metric=body.metric,
matrix=matrix,
unreachable_pairs=unreachable,
computation_time_ms=elapsed_ms,
)
async def _semantic_neighborhood_impl(
node_id: str,
top_k: int,
min_similarity: float,
session: GraphSession,
) -> SemanticNeighborhoodResponse:
node = await asyncio.to_thread(session.get_node, node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
similarity = session.similarity
if similarity is None:
raise HTTPException(
status_code=503,
detail="Semantic similarity is unavailable for this graph session.",
)
graph_dict = await asyncio.to_thread(session.build_graph_dict)
embeddings = _extract_node_embeddings(graph_dict)
query_embedding = embeddings.get(node_id)
if not embeddings or query_embedding is None:
raise HTTPException(
status_code=503,
detail="Semantic similarity is unavailable because this graph has no node embeddings.",
)
neighbors: List[SemanticNeighborItem] = []
try:
similar = await asyncio.to_thread(
similarity.find_most_similar,
embeddings,
query_embedding,
top_k=top_k * 2,
)
except Exception as exc:
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
raise HTTPException(
status_code=503,
detail="Semantic similarity search failed for this graph session.",
) from exc
# find_most_similar returns list of (node_id, score) or dicts
for item in similar:
if isinstance(item, (list, tuple)) and len(item) >= 2:
nid, sim_score = item[0], item[1]
elif isinstance(item, dict):
nid = item.get("node_id") or item.get("id", "")
sim_score = item.get("similarity", item.get("score", 0.0))
else:
continue
if float(sim_score) < min_similarity or nid == node_id:
continue
neighbor_node = await asyncio.to_thread(session.get_node, nid)
if neighbor_node is None:
continue
neighbors.append(
SemanticNeighborItem(
id=str(nid),
type=neighbor_node.get("type", ""),
content=neighbor_node.get("content", ""),
similarity=float(sim_score),
)
)
if len(neighbors) >= top_k:
break
return SemanticNeighborhoodResponse(
anchor_node=node_id,
neighbors=neighbors,
total=len(neighbors),
)
@router.get("/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
async def semantic_neighborhood_by_query(
node_id: str = Query(..., description="Anchor node ID"),
top_k: int = Query(20, ge=1, le=200),
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
session: GraphSession = Depends(get_session),
):
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
async def semantic_neighborhood(
node_id: str,
top_k: int = Query(20, ge=1, le=200),
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
session: GraphSession = Depends(get_session),
):
"""Deprecated path-segment route kept for backward compatibility.
Node IDs that contain slashes will return 404 because FastAPI decodes
%2F before route matching. Use GET /api/graph/semantic-neighborhood?node_id=...
for slash-safe semantic neighborhood lookup.
"""
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
@router.get("/stats", response_model=GraphStatsResponse)
async def graph_stats(
session: GraphSession = Depends(get_session),
+894
View File
@@ -0,0 +1,894 @@
"""
Ontology Hub routes: registry, URL/file loading, preview, creation, entity search, and SKOS.
"""
import asyncio
import ipaddress
import logging
import socket
import uuid
from datetime import UTC, datetime
from typing import Any, Dict, List, Literal, Optional
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from ..dependencies import get_session
from ..session import GraphSession
from ..utils.rdf_parser import _safe_parse_rdf
router = APIRouter(prefix="/api/ontology", tags=["Ontology"])
logger = logging.getLogger(__name__)
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # 20 MB
_CLASS_TYPES = frozenset({
"owl:Class", "rdfs:Class",
"http://www.w3.org/2002/07/owl#Class",
"http://www.w3.org/2000/01/rdf-schema#Class",
})
_PROPERTY_TYPES = frozenset({
"owl:ObjectProperty", "owl:DatatypeProperty", "owl:AnnotationProperty",
"rdfs:Property",
"http://www.w3.org/2002/07/owl#ObjectProperty",
"http://www.w3.org/2002/07/owl#DatatypeProperty",
"http://www.w3.org/2002/07/owl#AnnotationProperty",
})
_INDIVIDUAL_TYPES = frozenset({
"owl:NamedIndividual",
"http://www.w3.org/2002/07/owl#NamedIndividual",
})
_CONCEPT_TYPES = frozenset({
"skos:Concept",
"http://www.w3.org/2004/02/skos/core#Concept",
})
_SCHEME_TYPES = frozenset({
"skos:ConceptScheme",
"http://www.w3.org/2004/02/skos/core#ConceptScheme",
})
_ONTOLOGY_TYPES = frozenset({
"owl:Ontology",
"http://www.w3.org/2002/07/owl#Ontology",
}) | _SCHEME_TYPES
_SEARCHABLE_TYPES = _CLASS_TYPES | _PROPERTY_TYPES | _INDIVIDUAL_TYPES | _CONCEPT_TYPES | _SCHEME_TYPES
_URI_PREFIX_MAP = {
"http://www.w3.org/2002/07/owl#": "owl:",
"http://www.w3.org/2000/01/rdf-schema#": "rdfs:",
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:",
"http://www.w3.org/2004/02/skos/core#": "skos:",
"http://purl.org/dc/terms/": "dcterms:",
"http://purl.org/dc/elements/1.1/": "dc:",
"http://schema.org/": "schema:",
"http://www.w3.org/ns/shacl#": "sh:",
}
_FORMAT_ALIASES: Dict[str, str] = {
"ttl": "turtle",
"rdf": "xml",
"owl": "xml",
"jsonld": "json-ld",
"json": "json-ld",
}
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class OntologyEntry(BaseModel):
uri: str
name: str
description: Optional[str] = None
format: str = "unknown"
status: Literal["published", "draft", "external"] = "external"
source_url: Optional[str] = None
version: Optional[str] = None
class_count: int = 0
concept_count: int = 0
property_count: int = 0
loaded_at: str = ""
enabled: bool = True
tags: List[str] = Field(default_factory=list)
class OntologyPreview(BaseModel):
uri: str
name: str
description: Optional[str] = None
namespace: Optional[str] = None
version: Optional[str] = None
license: Optional[str] = None
format: str
estimated_triples: int = 0
source_url: Optional[str] = None
class LoadOntologyRequest(BaseModel):
url: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
class PreviewOntologyRequest(BaseModel):
url: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
class CreateOntologyRequest(BaseModel):
mode: Literal["scratch", "data", "text"] = "scratch"
namespace: str
name: str
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
sample_data: Optional[str] = None
schema_text: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
class OntologySearchResult(BaseModel):
uri: str
label: str
type: str
entity_type: str
definition: Optional[str] = None
source_ontology: Optional[str] = None
namespace_prefix: Optional[str] = None
class EntityDetailResponse(BaseModel):
uri: str
label: str
type: str
entity_type: str
definition: Optional[str] = None
source_ontology: Optional[str] = None
superclasses: List[str] = Field(default_factory=list)
subclasses: List[str] = Field(default_factory=list)
domain: List[str] = Field(default_factory=list)
range: List[str] = Field(default_factory=list)
instance_count: int = 0
properties: Dict[str, Any] = Field(default_factory=dict)
class SKOSScheme(BaseModel):
uri: str
title: str
description: Optional[str] = None
concept_count: int = 0
class SKOSConceptDetail(BaseModel):
uri: str
pref_label: str
alt_labels: List[str] = Field(default_factory=list)
hidden_labels: List[str] = Field(default_factory=list)
definition: Optional[str] = None
scope_note: Optional[str] = None
editorial_note: Optional[str] = None
broader: List[str] = Field(default_factory=list)
narrower: List[str] = Field(default_factory=list)
related: List[str] = Field(default_factory=list)
exact_match: List[str] = Field(default_factory=list)
close_match: List[str] = Field(default_factory=list)
broad_match: List[str] = Field(default_factory=list)
narrow_match: List[str] = Field(default_factory=list)
scheme_uri: Optional[str] = None
class LoadOntologyResponse(BaseModel):
status: str = "success"
uri: str
name: str
nodes_added: int = 0
edges_added: int = 0
format: str = "unknown"
class ToggleResponse(BaseModel):
uri: str
enabled: bool
class RefreshResponse(BaseModel):
status: str = "success"
uri: str
nodes_added: int = 0
edges_added: int = 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_registry(request: Request) -> Dict[str, OntologyEntry]:
if not hasattr(request.app.state, "ontology_registry"):
request.app.state.ontology_registry = {}
return request.app.state.ontology_registry
def _uri_to_prefix(uri: str) -> str:
for base, prefix in _URI_PREFIX_MAP.items():
if uri.startswith(base):
return prefix + uri[len(base):]
return uri
def _classify_node_type(node_type: str) -> str:
if node_type in _CLASS_TYPES:
return "class"
if node_type in _PROPERTY_TYPES:
return "property"
if node_type in _INDIVIDUAL_TYPES:
return "individual"
if node_type in _CONCEPT_TYPES:
return "concept"
if node_type in _SCHEME_TYPES:
return "scheme"
if node_type in _ONTOLOGY_TYPES:
return "ontology"
return "unknown"
def _node_label(node: Dict[str, Any]) -> str:
props = node.get("properties", {})
return (
props.get("pref_label")
or props.get("rdfs:label")
or props.get("skos:prefLabel")
or props.get("label")
or props.get("content")
or node.get("content", "")
or node.get("id", "")
)
def _extract_namespace(uri: str) -> Optional[str]:
if "#" in uri:
return uri.rsplit("#", 1)[0] + "#"
if "/" in uri:
return uri.rsplit("/", 1)[0] + "/"
return None
def _detect_format(content: str) -> str:
stripped = content.strip()[:500]
if stripped.startswith("{") or stripped.startswith("["):
return "json-ld"
if stripped.startswith("<"):
return "xml"
if "@prefix" in stripped or "@base" in stripped:
return "turtle"
# N-Triples blank-node subject: "_:word <predicate-uri> ..."
# URI-subject N-Triples ("<uri> <uri>") are already caught by the XML
# branch above, so only the blank-node form needs to be checked here.
# Plain string ops avoid the polynomial regex that CodeQL flags (py/polynomial-redos).
if stripped.startswith("_:") and " <" in stripped:
return "nt"
return "turtle"
def _normalize_format(fmt: Optional[str]) -> str:
if not fmt:
return "turtle"
lower = fmt.strip().lower()
return _FORMAT_ALIASES.get(lower, lower)
def _validate_fetch_url(url: str) -> None:
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=422, detail="Only http and https URLs are allowed.")
hostname = parsed.hostname
if not hostname:
raise HTTPException(status_code=422, detail="Invalid URL: missing hostname.")
try:
addrinfos = socket.getaddrinfo(hostname, None)
except socket.gaierror as exc:
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
try:
ip = ipaddress.ip_address(sockaddr[0])
except ValueError:
continue
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast:
raise HTTPException(
status_code=422,
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
)
def _fetch_url_sync(url: str) -> bytes:
_validate_fetch_url(url)
import requests as _req
try:
resp = _req.get(
url,
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
timeout=30,
stream=True,
allow_redirects=True,
)
resp.raise_for_status()
chunks: List[bytes] = []
total = 0
for chunk in resp.iter_content(65536):
total += len(chunk)
if total > _MAX_FETCH_BYTES:
raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.")
chunks.append(chunk)
return b"".join(chunks)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Could not fetch {url}: {exc}") from exc
def _parse_rdf_sync(content: bytes, fmt: str) -> tuple:
"""Return (nodes, edges, metadata). Raises HTTPException on failure."""
try:
import rdflib
except ImportError:
raise HTTPException(status_code=501, detail="rdflib is not installed.")
fmt_map = {
"turtle": "turtle", "xml": "xml", "nt": "nt",
"json-ld": "json-ld", "n3": "n3",
}
parse_fmt = fmt_map.get(fmt, "turtle")
g = rdflib.Graph()
try:
_safe_parse_rdf(g, content, parse_fmt)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"RDF parse error: {exc}") from exc
OWL = rdflib.Namespace("http://www.w3.org/2002/07/owl#")
RDF = rdflib.RDF
RDFS = rdflib.RDFS
SKOS = rdflib.Namespace("http://www.w3.org/2004/02/skos/core#")
DCT = rdflib.Namespace("http://purl.org/dc/terms/")
DC = rdflib.Namespace("http://purl.org/dc/elements/1.1/")
metadata: Dict[str, Any] = {}
for subj in g.subjects(RDF.type, OWL.Ontology):
metadata["uri"] = str(subj)
for pred, obj in g.predicate_objects(subj):
p = str(pred)
if p in {str(RDFS.label), str(DCT.title), str(DC.title)}:
metadata.setdefault("name", str(obj))
elif p in {str(RDFS.comment), str(DCT.description), str(DC.description)}:
metadata.setdefault("description", str(obj))
elif p == str(OWL.versionInfo):
metadata.setdefault("version", str(obj))
elif p in {str(DCT.license), str(DC.rights)}:
metadata.setdefault("license", str(obj))
break
if "uri" not in metadata:
for subj in g.subjects(RDF.type, SKOS.ConceptScheme):
metadata["uri"] = str(subj)
for pred, obj in g.predicate_objects(subj):
p = str(pred)
if p in {str(SKOS.prefLabel), str(DCT.title), str(DC.title)}:
metadata.setdefault("name", str(obj))
elif p in {str(SKOS.definition), str(DCT.description)}:
metadata.setdefault("description", str(obj))
break
if "uri" not in metadata:
metadata["uri"] = f"urn:semantica:onto:{uuid.uuid4().hex[:8]}"
metadata.setdefault("name", metadata["uri"].rsplit("/", 1)[-1].rsplit("#", 1)[-1] or "Unnamed")
metadata["triple_count"] = len(g)
# Collect literal properties per subject
literal_props: Dict[str, Dict[str, str]] = {}
for subj, pred, obj in g:
if isinstance(subj, rdflib.BNode) or not isinstance(obj, rdflib.Literal):
continue
sid = str(subj)
pk = _uri_to_prefix(str(pred))
literal_props.setdefault(sid, {})[pk] = str(obj)
# Build nodes from rdf:type statements
seen_ids: set = set()
nodes: List[Dict[str, Any]] = []
for subj, _, type_obj in g.triples((None, RDF.type, None)):
if isinstance(subj, rdflib.BNode):
continue
sid = str(subj)
ntype = _uri_to_prefix(str(type_obj))
if sid in seen_ids:
continue
seen_ids.add(sid)
props = dict(literal_props.get(sid, {}))
props["uri"] = sid
label = (
props.get("rdfs:label")
or props.get("skos:prefLabel")
or props.get("dcterms:title")
or sid.rsplit("/", 1)[-1].rsplit("#", 1)[-1]
)
nodes.append({"id": sid, "type": ntype, "content": label, "properties": props})
# Build edges from non-literal object statements
edges: List[Dict[str, Any]] = []
for subj, pred, obj in g:
if isinstance(subj, rdflib.BNode) or isinstance(obj, (rdflib.Literal, rdflib.BNode)):
continue
edges.append({
"source": str(subj),
"target": str(obj),
"type": _uri_to_prefix(str(pred)),
"weight": 1.0,
})
return nodes, edges, metadata
# ---------------------------------------------------------------------------
# Registry endpoints (all specific paths before wildcard)
# ---------------------------------------------------------------------------
@router.get("/registry", response_model=List[OntologyEntry])
async def list_registry(
request: Request,
q: Optional[str] = Query(None),
status: Optional[str] = Query(None),
format: Optional[str] = Query(None),
session: GraphSession = Depends(get_session),
):
registry = _get_registry(request)
# Discover ontology-type nodes from live graph not yet registered
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
# Count entity types per ontology URI via scheme_uri property
class_counts: Dict[str, int] = {}
concept_counts: Dict[str, int] = {}
prop_counts: Dict[str, int] = {}
implicit: Dict[str, Dict[str, Any]] = {}
for node in all_nodes:
ntype = node.get("type", "")
nid = node.get("id", "")
etype = _classify_node_type(ntype)
scheme_uri = node.get("properties", {}).get("scheme_uri") or node.get("properties", {}).get("uri")
if etype == "ontology" or etype == "scheme":
if nid and nid not in registry:
implicit[nid] = node
elif scheme_uri:
if etype == "class":
class_counts[scheme_uri] = class_counts.get(scheme_uri, 0) + 1
elif etype == "concept":
concept_counts[scheme_uri] = concept_counts.get(scheme_uri, 0) + 1
elif etype == "property":
prop_counts[scheme_uri] = prop_counts.get(scheme_uri, 0) + 1
result: List[OntologyEntry] = []
def _matches(name: str, uri: str, desc: str) -> bool:
if not q:
return True
ql = q.lower()
return any(ql in t.lower() for t in [name, uri, desc] if t)
for entry in registry.values():
if status and entry.status != status:
continue
if format and entry.format.lower() != format.lower():
continue
if not _matches(entry.name, entry.uri, entry.description or ""):
continue
updated = entry.model_copy(update={
"class_count": class_counts.get(entry.uri, entry.class_count),
"concept_count": concept_counts.get(entry.uri, entry.concept_count),
"property_count": prop_counts.get(entry.uri, entry.property_count),
})
result.append(updated)
for nid, node in implicit.items():
props = node.get("properties", {})
name = _node_label(node) or nid
if not _matches(name, nid, props.get("description", "")):
continue
result.append(OntologyEntry(
uri=nid,
name=name,
description=props.get("description"),
format=props.get("format", "unknown"),
status="external",
version=props.get("version") or props.get("owl:versionInfo"),
class_count=class_counts.get(nid, 0),
concept_count=concept_counts.get(nid, 0),
property_count=prop_counts.get(nid, 0),
loaded_at=props.get("loaded_at", ""),
enabled=True,
))
return result
@router.post("/preview", response_model=OntologyPreview)
async def preview_ontology(body: PreviewOntologyRequest):
if not body.url and not body.content:
raise HTTPException(status_code=422, detail="Provide either url or content.")
if body.url:
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
content_str = raw.decode("utf-8", errors="replace")
else:
content_str = body.content or ""
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
try:
_, _, metadata = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), fmt
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
return OntologyPreview(
uri=metadata.get("uri", ""),
name=metadata.get("name", ""),
description=metadata.get("description"),
namespace=_extract_namespace(metadata.get("uri", "")),
version=metadata.get("version"),
license=metadata.get("license"),
format=fmt,
estimated_triples=metadata.get("triple_count", 0),
source_url=body.url,
)
@router.post("/load", response_model=LoadOntologyResponse)
async def load_ontology(
request: Request,
body: LoadOntologyRequest,
session: GraphSession = Depends(get_session),
):
if not body.url and not body.content:
raise HTTPException(status_code=422, detail="Provide either url or content.")
if body.url:
raw = await asyncio.to_thread(_fetch_url_sync, body.url)
content_str = raw.decode("utf-8", errors="replace")
else:
content_str = body.content or ""
fmt = _normalize_format(body.format) if body.format else _detect_format(content_str)
try:
nodes, edges, metadata = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), fmt
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Could not parse ontology: {exc}") from exc
onto_uri = metadata.get("uri", f"urn:semantica:onto:{uuid.uuid4().hex[:8]}")
onto_name = body.name or metadata.get("name", "Unnamed Ontology")
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
registry = _get_registry(request)
registry[onto_uri] = OntologyEntry(
uri=onto_uri,
name=onto_name,
description=body.description or metadata.get("description"),
format=fmt,
status="external",
source_url=body.url,
version=metadata.get("version"),
class_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "class"),
concept_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) in ("concept", "scheme")),
property_count=sum(1 for n in nodes if _classify_node_type(n.get("type", "")) == "property"),
loaded_at=datetime.now(UTC).isoformat(),
enabled=True,
tags=body.tags,
)
return LoadOntologyResponse(
uri=onto_uri, name=onto_name,
nodes_added=nodes_added, edges_added=edges_added, format=fmt,
)
@router.post("/create", response_model=LoadOntologyResponse)
async def create_ontology(
request: Request,
body: CreateOntologyRequest,
session: GraphSession = Depends(get_session),
):
ns = body.namespace.rstrip("/#")
onto_uri = f"{ns}#ontology"
nodes: List[Dict[str, Any]] = [{
"id": onto_uri,
"type": "owl:Ontology",
"content": body.name,
"properties": {
"rdfs:label": body.name,
"rdfs:comment": body.description or "",
"namespace": body.namespace,
},
}]
edges: List[Dict[str, Any]] = []
if body.mode == "data" and body.sample_data:
try:
from ...ontology import OntologyEngine
engine = OntologyEngine()
result = await asyncio.to_thread(engine.from_data, body.sample_data)
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
nodes.append({
"id": cls_uri, "type": "owl:Class",
"content": cls.get("name", ""),
"properties": {"rdfs:label": cls.get("name", "")},
})
except Exception:
logger.exception("Failed to generate ontology from sample data; falling back to minimal ontology.")
elif body.mode == "text" and body.schema_text:
try:
from ...ontology import OntologyEngine
engine = OntologyEngine()
result = await asyncio.to_thread(engine.from_text, body.schema_text)
for cls in (result.get("classes", []) if isinstance(result, dict) else []):
cls_uri = f"{ns}/{cls.get('name', uuid.uuid4().hex[:6])}"
nodes.append({
"id": cls_uri, "type": "owl:Class",
"content": cls.get("name", ""),
"properties": {"rdfs:label": cls.get("name", "")},
})
except Exception:
logger.exception("Failed to generate ontology from schema text; falling back to minimal ontology.")
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
registry = _get_registry(request)
registry[onto_uri] = OntologyEntry(
uri=onto_uri,
name=body.name,
description=body.description,
format="turtle",
status="draft",
version="0.1.0",
class_count=sum(1 for n in nodes if n.get("type") == "owl:Class"),
loaded_at=datetime.now(UTC).isoformat(),
enabled=True,
tags=body.tags,
)
return LoadOntologyResponse(
uri=onto_uri, name=body.name,
nodes_added=nodes_added, edges_added=edges_added, format="turtle",
)
@router.get("/search", response_model=List[OntologySearchResult])
async def search_entities(
q: str = Query(..., min_length=1),
entity_type: Optional[str] = Query(None),
limit: int = Query(default=50, ge=1, le=200),
session: GraphSession = Depends(get_session),
):
# Use the session's indexed search; over-fetch to allow post-filtering by entity type
raw_hits = await asyncio.to_thread(session.search, q, limit * 6)
results: List[OntologySearchResult] = []
for hit in raw_hits:
node = hit.get("node", hit) # session.search returns {"node": ..., "score": ...}
ntype = node.get("type", "")
if ntype not in _SEARCHABLE_TYPES:
continue
etype = _classify_node_type(ntype)
if entity_type and etype != entity_type:
continue
label = _node_label(node)
props = node.get("properties", {})
definition = (
props.get("rdfs:comment")
or props.get("skos:definition")
or props.get("description")
)
results.append(OntologySearchResult(
uri=node.get("id", ""),
label=label,
type=ntype,
entity_type=etype,
definition=definition,
source_ontology=props.get("scheme_uri"),
namespace_prefix=_extract_namespace(node.get("id", "")),
))
if len(results) >= limit:
break
return results
@router.get("/entity/{entity_uri:path}", response_model=EntityDetailResponse)
async def get_entity_detail(
entity_uri: str,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, entity_uri)
if node is None:
raise HTTPException(status_code=404, detail="Entity not found.")
props = node.get("properties", {})
ntype = node.get("type", "")
label = _node_label(node)
definition = props.get("rdfs:comment") or props.get("skos:definition") or props.get("description")
out_edges, _ = await asyncio.to_thread(session.get_edges, source=entity_uri, skip=0, limit=9999)
in_edges, _ = await asyncio.to_thread(session.get_edges, target=entity_uri, skip=0, limit=9999)
superclasses = [e["target"] for e in out_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
subclasses = [e["source"] for e in in_edges if e.get("type") in {"rdfs:subClassOf", "skos:broader"}]
domain = [e["target"] for e in out_edges if e.get("type") == "rdfs:domain"]
range_ = [e["target"] for e in out_edges if e.get("type") == "rdfs:range"]
all_nodes, _ = await asyncio.to_thread(session.get_nodes, skip=0, limit=999_999)
instance_count = sum(1 for n in all_nodes if n.get("type") == entity_uri)
return EntityDetailResponse(
uri=entity_uri, label=label,
type=ntype, entity_type=_classify_node_type(ntype),
definition=definition,
source_ontology=props.get("scheme_uri"),
superclasses=superclasses, subclasses=subclasses,
domain=domain, range=range_,
instance_count=instance_count, properties=props,
)
@router.get("/skos/schemes", response_model=List[SKOSScheme])
async def list_skos_schemes(session: GraphSession = Depends(get_session)):
nodes, _ = await asyncio.to_thread(
session.get_nodes, node_type="skos:ConceptScheme", skip=0, limit=999_999
)
# Count concepts per scheme from edges
all_edges, _ = await asyncio.to_thread(session.get_edges, skip=0, limit=999_999)
concept_counts: Dict[str, int] = {}
for edge in all_edges:
if edge.get("type") in {"skos:inScheme", "skos:topConceptOf"}:
concept_counts[edge["target"]] = concept_counts.get(edge["target"], 0) + 1
elif edge.get("type") == "skos:hasTopConcept":
concept_counts[edge["source"]] = concept_counts.get(edge["source"], 0) + 1
result = []
for node in nodes:
props = node.get("properties", {})
nid = node.get("id", "")
result.append(SKOSScheme(
uri=nid,
title=_node_label(node),
description=props.get("description") or props.get("skos:definition"),
concept_count=concept_counts.get(nid, 0),
))
return result
@router.get("/skos/concept/{concept_uri:path}", response_model=SKOSConceptDetail)
async def get_skos_concept(
concept_uri: str,
session: GraphSession = Depends(get_session),
):
node = await asyncio.to_thread(session.get_node, concept_uri)
if node is None:
raise HTTPException(status_code=404, detail="Concept not found.")
props = node.get("properties", {})
out_edges, _ = await asyncio.to_thread(session.get_edges, source=concept_uri, skip=0, limit=9999)
in_edges, _ = await asyncio.to_thread(session.get_edges, target=concept_uri, skip=0, limit=9999)
def collect_out(rel: str) -> List[str]:
return [e["target"] for e in out_edges if e.get("type") == rel]
def collect_in(rel: str) -> List[str]:
return [e["source"] for e in in_edges if e.get("type") == rel]
pref_label = props.get("pref_label") or props.get("skos:prefLabel") or _node_label(node)
alt_labels = props.get("alt_labels") or props.get("skos:altLabel") or []
if isinstance(alt_labels, str):
alt_labels = [alt_labels]
hidden_labels = props.get("skos:hiddenLabel") or []
if isinstance(hidden_labels, str):
hidden_labels = [hidden_labels]
scheme_uri = props.get("scheme_uri")
if not scheme_uri:
candidates = collect_out("skos:inScheme") or collect_out("skos:topConceptOf")
scheme_uri = candidates[0] if candidates else None
return SKOSConceptDetail(
uri=concept_uri,
pref_label=pref_label,
alt_labels=list(alt_labels),
hidden_labels=list(hidden_labels),
definition=props.get("definition") or props.get("skos:definition"),
scope_note=props.get("skos:scopeNote"),
editorial_note=props.get("skos:editorialNote"),
broader=collect_out("skos:broader") + collect_in("skos:narrower"),
narrower=collect_out("skos:narrower") + collect_in("skos:broader"),
related=collect_out("skos:related"),
exact_match=collect_out("skos:exactMatch"),
close_match=collect_out("skos:closeMatch"),
broad_match=collect_out("skos:broadMatch"),
narrow_match=collect_out("skos:narrowMatch"),
scheme_uri=scheme_uri,
)
# ---------------------------------------------------------------------------
# Wildcard management endpoints (must come after specific routes)
# ---------------------------------------------------------------------------
@router.delete("/{ontology_uri:path}")
async def remove_ontology(ontology_uri: str, request: Request):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
del registry[ontology_uri]
return {"status": "removed", "uri": ontology_uri}
@router.patch("/{ontology_uri:path}/toggle", response_model=ToggleResponse)
async def toggle_ontology(ontology_uri: str, request: Request):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
entry = registry[ontology_uri]
entry.enabled = not entry.enabled
return ToggleResponse(uri=ontology_uri, enabled=entry.enabled)
@router.post("/{ontology_uri:path}/refresh", response_model=RefreshResponse)
async def refresh_ontology(
ontology_uri: str,
request: Request,
session: GraphSession = Depends(get_session),
):
registry = _get_registry(request)
if ontology_uri not in registry:
raise HTTPException(status_code=404, detail="Ontology not found in registry.")
entry = registry[ontology_uri]
if not entry.source_url:
raise HTTPException(status_code=422, detail="No source URL to refresh from.")
raw = await asyncio.to_thread(_fetch_url_sync, entry.source_url)
content_str = raw.decode("utf-8", errors="replace")
try:
nodes, edges, _ = await asyncio.to_thread(
_parse_rdf_sync, content_str.encode("utf-8"), entry.format
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Refresh parse error: {exc}") from exc
nodes_added = await asyncio.to_thread(session.add_nodes, nodes)
edges_added = await asyncio.to_thread(session.add_edges, edges)
entry.loaded_at = datetime.now(UTC).isoformat()
return RefreshResponse(uri=ontology_uri, nodes_added=nodes_added, edges_added=edges_added)
+34 -27
View File
@@ -1,4 +1,4 @@
"""
"""
Provenance routes for lineage visualization and exportable reports.
"""
@@ -9,33 +9,13 @@ from typing import Any, Dict, List, Optional
import networkx as nx
from fastapi import APIRouter, Depends, Query
from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel
from ..dependencies import get_session
from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
from ..session import GraphSession
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
class ProvenanceNode(BaseModel):
id: str
label: str
prov_type: str
parent_id: str
class ProvenanceEdge(BaseModel):
id: str
source: str
target: str
label: str
class ProvenanceResponse(BaseModel):
nodes: List[ProvenanceNode]
edges: List[ProvenanceEdge]
_AGENT_TYPES = {"person", "organization", "system", "agent"}
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
@@ -67,7 +47,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
provenance_nodes: List[Dict[str, Any]] = []
for graph_node_id in subgraph.nodes():
node = session.graph.nodes.get(graph_node_id)
@@ -85,12 +65,19 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
provenance_edges: List[Dict[str, Any]] = []
for source, target, data in subgraph.edges(data=True):
if target == node_id:
direction = "upstream"
elif source == node_id:
direction = "downstream"
else:
direction = "lateral"
provenance_edges.append(
{
"id": f"{source}-{target}",
"source": source,
"target": target,
"label": data.get("label", "related_to"),
"direction": direction,
}
)
@@ -104,7 +91,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
"node_id": node_id,
"label": node.get("content", node_id) if node else node_id,
"type": node.get("type", "entity") if node else "entity",
"properties": node.get("properties", {}) if node else {},
"properties": node.get("metadata", node.get("properties", {})) if node else {},
"lineage": provenance,
}
@@ -129,9 +116,29 @@ def _render_markdown(report: Dict[str, Any]) -> str:
for node in report.get("lineage", {}).get("nodes", []):
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
lines.extend(["", "## Lineage Edges"])
for edge in report.get("lineage", {}).get("edges", []):
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
edges = report.get("lineage", {}).get("edges", [])
grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
for edge in edges:
direction = edge.get("direction", "lateral")
if direction not in grouped_edges:
direction = "lateral"
grouped_edges[direction].append(edge)
if grouped_edges["upstream"]:
lines.extend(["", "## Upstream"])
for edge in grouped_edges["upstream"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
if grouped_edges["downstream"]:
lines.extend(["", "## Downstream"])
for edge in grouped_edges["downstream"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
if grouped_edges["lateral"]:
lines.extend(["", "## Lateral"])
for edge in grouped_edges["lateral"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
return "\n".join(lines)
+127 -2
View File
@@ -5,14 +5,20 @@ Temporal routes for snapshots, diffs, and pattern detection.
import asyncio
import logging
import re
from datetime import datetime, timezone, UTC
from datetime import datetime, timedelta, timezone, UTC
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from ..dependencies import get_session
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
from ..schemas import (
DistanceEvent,
DistanceHistoryResponse,
DistanceSnapshot,
TemporalDiffResponse,
TemporalPatternResponse,
)
from ..session import GraphSession
logger = logging.getLogger(__name__)
@@ -120,3 +126,122 @@ async def temporal_bounds(
):
bounds = await asyncio.to_thread(session.get_temporal_bounds)
return TemporalBoundsResponse(**bounds)
@router.get("/distance-history", response_model=DistanceHistoryResponse)
async def distance_history(
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
metric: str = Query("hops", description="Distance metric: hops | weighted"),
session: GraphSession = Depends(get_session),
):
"""FR-9 — Track distance changes between two nodes across temporal snapshots."""
from ...utils.helpers import classify_path_distance
bounds = await asyncio.to_thread(session.get_temporal_bounds)
min_bound_str = bounds.get("min")
max_bound_str = bounds.get("max")
if not min_bound_str or not max_bound_str:
# No temporal data — return current-only snapshot
pf = session.path_finder
hop_count: Optional[int] = None
if pf is not None:
try:
graph_dict = await asyncio.to_thread(session.build_graph_dict)
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
hop_count = len(path_nodes) - 1 if path_nodes else None
except Exception as exc:
logger.warning(
"distance_history path computation failed for source=%r target=%r metric=%r: %s",
source, target, metric, exc, exc_info=True,
)
now = datetime.now(UTC).replace(tzinfo=None)
snap = DistanceSnapshot(
timestamp=now,
hop_count=hop_count,
distance_band=classify_path_distance(hop_count) if hop_count is not None else "distant",
)
return DistanceHistoryResponse(
source_id=source, target_id=target, metric=metric,
history=[snap], events=[],
)
min_bound = _parse_query_dt(min_bound_str)
max_bound = _parse_query_dt(max_bound_str)
# Sample up to 10 snapshots evenly between min and max
total_seconds = max(1, int((max_bound - min_bound).total_seconds()))
step = total_seconds / min(10, total_seconds)
sample_times = [
min_bound + timedelta(seconds=int(i * step))
for i in range(11)
]
pf = session.path_finder
history: List[DistanceSnapshot] = []
events: List[DistanceEvent] = []
prev_hop: Optional[int] = None
for sample_time in sample_times:
active_nodes = await asyncio.to_thread(session.get_active_nodes, at_time=sample_time)
active_ids = {n.get("id") for n in active_nodes if n.get("id")}
hop_count = None
if source in active_ids and target in active_ids and pf is not None:
try:
graph_dict = await asyncio.to_thread(
session.build_graph_dict, list(active_ids)
)
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
hop_count = len(path_nodes) - 1 if path_nodes else None
except Exception as exc:
logger.warning(
"distance_history path computation failed for source=%r target=%r at=%s metric=%s: %s",
source, target, sample_time.isoformat(), metric, exc, exc_info=True,
)
hop_count = None
band = classify_path_distance(hop_count) if hop_count is not None else "distant"
snap = DistanceSnapshot(timestamp=sample_time, hop_count=hop_count, distance_band=band)
history.append(snap)
# Detect events relative to previous snapshot
if prev_hop is not None or hop_count is not None:
if prev_hop is None and hop_count is not None:
events.append(DistanceEvent(
timestamp=sample_time,
event_type="reconnected",
hop_count_before=None,
hop_count_after=hop_count,
description=f"Nodes reconnected at {hop_count} hop(s) on {sample_time.date()}.",
))
elif prev_hop is not None and hop_count is None:
events.append(DistanceEvent(
timestamp=sample_time,
event_type="disconnected",
hop_count_before=prev_hop,
hop_count_after=None,
description=f"Nodes became unreachable on {sample_time.date()}.",
))
elif prev_hop is not None and hop_count is not None and hop_count != prev_hop:
etype = "convergence" if hop_count < prev_hop else "divergence"
events.append(DistanceEvent(
timestamp=sample_time,
event_type=etype,
hop_count_before=prev_hop,
hop_count_after=hop_count,
description=(
f"Nodes {etype}d from {prev_hop} hops to {hop_count} hops "
f"on {sample_time.date()}."
),
))
prev_hop = hop_count
return DistanceHistoryResponse(
source_id=source, target_id=target, metric=metric,
history=history, events=events,
)
+128 -1
View File
@@ -2,7 +2,8 @@
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
"""
from typing import Any, Dict, List, Optional
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, Field
@@ -67,6 +68,16 @@ class PathResponse(BaseModel):
path: List[str]
edge_ids: List[str] = Field(default_factory=list)
total_weight: float = 0.0
directed: bool = True
hop_count: int = 0
distance_band: str = "direct"
# FR-4 enrichment fields — all optional; existing callers unaffected
semantic_similarity: Optional[float] = None
path_coherence_score: Optional[float] = None
confidence_decay: Optional[float] = None
bottleneck_node: Optional[str] = None
alternative_path_count: int = 0
interpretation: str = ""
class GraphStatsResponse(BaseModel):
@@ -81,11 +92,19 @@ class SearchRequest(BaseModel):
query: str
filters: Dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=20, ge=1, le=200)
# FR-7 proximity constraint fields
anchor_node: Optional[str] = None
max_hops: Optional[int] = None
min_semantic_similarity: Optional[float] = None
rank_by: Literal["relevance", "proximity", "hybrid"] = "relevance"
class SearchResultItem(BaseModel):
node: NodeResponse
score: float = 0.0
# FR-7 distance metadata
hop_distance: Optional[int] = None
semantic_similarity: Optional[float] = None
class SearchResultResponse(BaseModel):
@@ -285,3 +304,111 @@ class MergeResponse(BaseModel):
merged_into: str
removed_ids: List[str]
edges_updated: int
class ProvenanceNode(BaseModel):
id: str
label: str
prov_type: str
parent_id: Optional[str] = None
class ProvenanceEdge(BaseModel):
id: str
source: str
target: str
label: str
direction: str
class ProvenanceResponse(BaseModel):
nodes: List[ProvenanceNode]
edges: List[ProvenanceEdge]
# ---------------------------------------------------------------------------
# FR-6 — Distance Matrix API
# ---------------------------------------------------------------------------
class DistanceMatrixRequest(BaseModel):
node_ids: List[str]
metric: Literal["hops", "weighted", "semantic"] = "hops"
class DistanceMatrixResponse(BaseModel):
nodes: List[str]
metric: str
matrix: List[List[Optional[float]]]
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
computation_time_ms: float
# ---------------------------------------------------------------------------
# FR-3 backend — Semantic Neighborhood
# ---------------------------------------------------------------------------
class SemanticNeighborItem(BaseModel):
id: str
type: str
content: str = ""
similarity: float
hop_distance: Optional[int] = None
class SemanticNeighborhoodResponse(BaseModel):
anchor_node: str
neighbors: List[SemanticNeighborItem]
total: int
# ---------------------------------------------------------------------------
# FR-8 — Causal Distance Report
# ---------------------------------------------------------------------------
class CausalDistanceReport(BaseModel):
source_id: str
target_id: str
causal_path: List[str]
causal_hop_count: int
intermediate_decisions: List[str]
confidence_decay: float
weakest_link: Optional[Dict[str, Any]] = None
interpretation: str
# ---------------------------------------------------------------------------
# FR-9 — Temporal Distance Alerts
# ---------------------------------------------------------------------------
class DistanceSnapshot(BaseModel):
timestamp: datetime
hop_count: Optional[int] = None
distance_band: str
class DistanceEvent(BaseModel):
timestamp: datetime
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
hop_count_before: Optional[int] = None
hop_count_after: Optional[int] = None
description: str
class DistanceHistoryResponse(BaseModel):
source_id: str
target_id: str
metric: str
history: List[DistanceSnapshot]
events: List[DistanceEvent]
# ---------------------------------------------------------------------------
# FR-10 — Distance-Enriched Export
# ---------------------------------------------------------------------------
class DistanceExportRequest(BaseModel):
format: Literal["csv", "jsonl"] = "csv"
node_subset: Optional[List[str]] = None
include: List[str] = Field(
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
)
+398
View File
@@ -0,0 +1,398 @@
"""
Explorer-local in-memory node search index.
"""
from __future__ import annotations
import bisect
import heapq
import re
from collections import OrderedDict, defaultdict
from dataclasses import dataclass
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple
_TOKEN_RE = re.compile(r"[a-z0-9]+")
_WHITESPACE_RE = re.compile(r"\s+")
_CURATED_ALIAS_KEYS = (
"label",
"name",
"title",
"pref_label",
"preferred_label",
"prefLabel",
"aliases",
"alias",
"synonyms",
"synonym",
"symbol",
"display_name",
"displayName",
"text",
"content",
)
def _normalize_text(value: Any) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if not text:
return ""
return _WHITESPACE_RE.sub(" ", text)
def _tokenize(text: str) -> Tuple[str, ...]:
if not text:
return ()
return tuple(dict.fromkeys(_TOKEN_RE.findall(text)))
def _collect_text_fragments(value: Any, fragments: List[str], *, limit: int = 64) -> None:
if value is None or len(fragments) >= limit:
return
if isinstance(value, dict):
for nested in value.values():
_collect_text_fragments(nested, fragments, limit=limit)
if len(fragments) >= limit:
return
return
if isinstance(value, (list, tuple, set)):
for nested in value:
_collect_text_fragments(nested, fragments, limit=limit)
if len(fragments) >= limit:
return
return
normalized = _normalize_text(value)
if normalized:
fragments.append(normalized)
def _coerce_float(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass(frozen=True)
class IndexedNodeDocument:
node_id: str
normalized_id: str
node_type: str
exact_terms: frozenset[str]
tokens: frozenset[str]
primary_text: str
secondary_text: str
confidence: Optional[float]
tags: Tuple[str, ...]
class GraphSearchIndex:
def __init__(
self,
*,
cache_size: int = 128,
prefix_min_length: int = 2,
prefix_max_length: int = 12,
secondary_scan_limit: int = 12000,
) -> None:
self.cache_size = cache_size
self.prefix_min_length = prefix_min_length
self.prefix_max_length = prefix_max_length
self.secondary_scan_limit = secondary_scan_limit
self._documents: Dict[str, IndexedNodeDocument] = {}
self._exact_index: DefaultDict[str, set[str]] = defaultdict(set)
self._token_index: DefaultDict[str, set[str]] = defaultdict(set)
self._prefix_index: DefaultDict[str, set[str]] = defaultdict(set)
self._ordered_node_ids: List[str] = []
self._cache: OrderedDict[Tuple[Any, ...], List[Tuple[str, float]]] = OrderedDict()
def rebuild(self, nodes: Iterable[Dict[str, Any]]) -> None:
self._documents.clear()
self._exact_index.clear()
self._token_index.clear()
self._prefix_index.clear()
self._ordered_node_ids = []
self.clear_cache()
for node in nodes:
self.upsert(node, clear_cache=False)
self._ordered_node_ids.sort()
def clear_cache(self) -> None:
self._cache.clear()
def remove(self, node_id: str, *, clear_cache: bool = True) -> None:
existing = self._documents.pop(node_id, None)
if existing is None:
return
for term in existing.exact_terms:
bucket = self._exact_index.get(term)
if bucket is None:
continue
bucket.discard(node_id)
if not bucket:
self._exact_index.pop(term, None)
for token in existing.tokens:
bucket = self._token_index.get(token)
if bucket is None:
continue
bucket.discard(node_id)
if not bucket:
self._token_index.pop(token, None)
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
prefix = token[:length]
prefix_bucket = self._prefix_index.get(prefix)
if prefix_bucket is None:
continue
prefix_bucket.discard(node_id)
if not prefix_bucket:
self._prefix_index.pop(prefix, None)
pos = bisect.bisect_left(self._ordered_node_ids, node_id)
if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
self._ordered_node_ids.pop(pos)
if clear_cache:
self.clear_cache()
def upsert(self, node: Dict[str, Any], *, clear_cache: bool = True) -> None:
node_id = str(node.get("id", "")).strip()
if not node_id:
return
self.remove(node_id, clear_cache=False)
document = self._build_document(node)
self._documents[node_id] = document
for term in document.exact_terms:
self._exact_index[term].add(node_id)
for token in document.tokens:
self._token_index[token].add(node_id)
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
self._prefix_index[token[:length]].add(node_id)
bisect.insort(self._ordered_node_ids, node_id)
if clear_cache:
self.clear_cache()
def search(
self,
query: str,
*,
limit: int = 20,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Tuple[str, float]], Dict[str, Any]]:
normalized_query = _normalize_text(query)
filters = filters or {}
diagnostics: Dict[str, Any] = {
"cache_hit": False,
"path": "empty",
"candidates": 0,
}
if not normalized_query:
return [], diagnostics
cache_key = self._cache_key(normalized_query, limit, filters)
cached = self._cache.get(cache_key)
if cached is not None:
self._cache.move_to_end(cache_key)
diagnostics.update({"cache_hit": True, "path": "cache", "candidates": len(cached)})
return list(cached), diagnostics
query_tokens = _tokenize(normalized_query)
exact_ids = set(self._exact_index.get(normalized_query, set()))
token_sets: List[set[str]] = []
prefix_sets: List[set[str]] = []
for token in query_tokens:
exact_token_ids = set(self._token_index.get(token, set()))
prefix_ids = set(self._prefix_index.get(token, set())) if len(token) >= self.prefix_min_length else set()
if exact_token_ids:
token_sets.append(exact_token_ids)
if prefix_ids:
prefix_sets.append(prefix_ids)
candidate_ids: set[str] = set(exact_ids)
if token_sets:
intersected = set.intersection(*token_sets)
candidate_ids.update(intersected if intersected else set().union(*token_sets))
if prefix_sets:
candidate_ids.update(set().union(*prefix_sets))
diagnostics["path"] = "index"
if not candidate_ids:
diagnostics["path"] = "secondary_scan"
candidate_ids = self._secondary_scan(normalized_query, limit)
diagnostics["candidates"] = len(candidate_ids)
scored: List[Tuple[float, int, int, str]] = []
for node_id in candidate_ids:
document = self._documents.get(node_id)
if document is None or not self._passes_filters(document, filters):
continue
score = self._score_document(document, normalized_query, query_tokens)
if score <= 0:
continue
token_hits = sum(1 for token in query_tokens if token in document.tokens)
exactness = 1 if normalized_query == document.normalized_id or normalized_query in document.exact_terms else 0
scored.append((score, exactness, token_hits, node_id))
top_matches = heapq.nlargest(limit, scored, key=lambda item: (item[0], item[1], item[2], item[3]))
results = [(node_id, round(score, 4)) for score, _, _, node_id in top_matches]
self._store_cache(cache_key, results)
return results, diagnostics
def _secondary_scan(self, normalized_query: str, limit: int) -> set[str]:
matches: set[str] = set()
max_hits = max(limit * 20, 200)
scanned = 0
for node_id in self._ordered_node_ids:
if scanned >= self.secondary_scan_limit or len(matches) >= max_hits:
break
scanned += 1
document = self._documents.get(node_id)
if document is None:
continue
if normalized_query in document.primary_text or normalized_query in document.secondary_text:
matches.add(node_id)
return matches
def _score_document(
self,
document: IndexedNodeDocument,
normalized_query: str,
query_tokens: Tuple[str, ...],
) -> float:
score = 0.0
if normalized_query == document.normalized_id:
score = max(score, 140.0)
elif normalized_query in document.exact_terms:
score = max(score, 120.0)
if normalized_query and normalized_query in document.primary_text:
score = max(score, 78.0 + min(len(normalized_query), 24) / 10.0)
elif normalized_query and normalized_query in document.secondary_text:
score = max(score, 26.0 + min(len(normalized_query), 24) / 20.0)
token_hits = 0
prefix_hits = 0
for token in query_tokens:
if token in document.tokens:
token_hits += 1
elif len(token) >= self.prefix_min_length and any(candidate.startswith(token) for candidate in document.tokens):
prefix_hits += 1
score += token_hits * 18.0
score += prefix_hits * 10.0
if len(query_tokens) > 1 and token_hits:
score += token_hits * 4.0
return score
def _passes_filters(self, document: IndexedNodeDocument, filters: Dict[str, Any]) -> bool:
filter_type = filters.get("type") or filters.get("node_type")
if filter_type and document.node_type != str(filter_type):
return False
min_confidence = _coerce_float(filters.get("min_confidence"))
if min_confidence is not None:
if document.confidence is None or document.confidence < min_confidence:
return False
tags_filter = filters.get("tags")
if tags_filter:
if isinstance(tags_filter, str):
required_tags = {_normalize_text(tags_filter)}
else:
required_tags = {
normalized
for normalized in (_normalize_text(tag) for tag in tags_filter)
if normalized
}
if required_tags and not required_tags.issubset(set(document.tags)):
return False
return True
def _cache_key(
self,
normalized_query: str,
limit: int,
filters: Dict[str, Any],
) -> Tuple[Any, ...]:
serialized_filters: List[Tuple[str, Any]] = []
for key in sorted(filters.keys()):
value = filters[key]
if isinstance(value, (list, tuple, set)):
serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
else:
serialized_filters.append((key, str(value)))
return normalized_query, limit, tuple(serialized_filters)
def _store_cache(self, cache_key: Tuple[Any, ...], results: List[Tuple[str, float]]) -> None:
self._cache[cache_key] = list(results)
self._cache.move_to_end(cache_key)
while len(self._cache) > self.cache_size:
self._cache.popitem(last=False)
def _build_document(self, node: Dict[str, Any]) -> IndexedNodeDocument:
node_id = str(node.get("id", "")).strip()
node_type = str(node.get("type", "entity"))
properties = dict(node.get("properties", {}) or {})
primary_terms: List[str] = []
for candidate in (node_id, node.get("content", "")):
normalized = _normalize_text(candidate)
if normalized:
primary_terms.append(normalized)
for alias_key in _CURATED_ALIAS_KEYS:
_collect_text_fragments(properties.get(alias_key), primary_terms, limit=32)
deduped_primary_terms = tuple(dict.fromkeys(term for term in primary_terms if term))
primary_text = " ".join(deduped_primary_terms)
tokens = frozenset(_tokenize(primary_text))
secondary_fragments: List[str] = []
for key, value in properties.items():
if key in _CURATED_ALIAS_KEYS or key in {"content", "valid_from", "valid_until"}:
continue
_collect_text_fragments(value, secondary_fragments, limit=48)
if len(secondary_fragments) >= 48:
break
secondary_text = " ".join(dict.fromkeys(fragment for fragment in secondary_fragments if fragment))
confidence = _coerce_float(properties.get("confidence"))
raw_tags = properties.get("tags") or []
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
tags = tuple(
dict.fromkeys(
normalized for normalized in (_normalize_text(tag) for tag in raw_tags) if normalized
)
)
return IndexedNodeDocument(
node_id=node_id,
normalized_id=_normalize_text(node_id),
node_type=node_type,
exact_terms=frozenset(deduped_primary_terms),
tokens=tokens,
primary_text=primary_text,
secondary_text=secondary_text,
confidence=confidence,
tags=tags,
)
+100 -58
View File
@@ -4,12 +4,15 @@ Semantica Explorer session helpers.
import base64
import json
import logging
import threading
import time
import uuid
from datetime import datetime, UTC
from datetime import UTC, datetime
from typing import Any, Dict, Iterable, List, Optional
from ..context.context_graph import ContextGraph, _resolve_edge_identity
from .search_index import GraphSearchIndex
_KG_AVAILABLE = False
try:
@@ -28,6 +31,8 @@ try:
except ImportError:
pass
logger = logging.getLogger(__name__)
class GraphSession:
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
@@ -35,6 +40,7 @@ class GraphSession:
def __init__(self, graph: ContextGraph) -> None:
self.graph = graph
self._lock = threading.RLock()
self._search_index = GraphSearchIndex()
self.annotations: Dict[str, Dict[str, Any]] = {}
@@ -46,6 +52,7 @@ class GraphSession:
self._similarity: Any = None
self._link_predictor: Any = None
self._validator: Any = None
self.rebuild_search_index()
@classmethod
def from_file(cls, path: str) -> "GraphSession":
@@ -390,6 +397,28 @@ class GraphSession:
with self._lock:
return self.graph.get_neighbors(node_id, hops=depth)
def rebuild_search_index(self) -> None:
with self._lock:
normalized_nodes = [
self.normalize_node(node.to_dict())
for node in self.graph.nodes.values()
if node is not None
]
self._search_index.rebuild(normalized_nodes)
def handle_graph_mutation(self, event_type: str, entity_id: str, payload: Dict[str, Any]) -> None:
normalized_event = str(event_type or "").upper()
if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
normalized_node = self.normalize_node(payload or {})
if normalized_node.get("id"):
with self._lock:
self._search_index.upsert(normalized_node)
elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
with self._lock:
self._search_index.remove(str(entity_id))
elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
self.rebuild_search_index()
def search(
self,
query: str,
@@ -397,64 +426,34 @@ class GraphSession:
filters: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
filters = filters or {}
try:
with self._lock:
raw = self.graph.query(query)[:limit]
except Exception:
raw = []
started_at = time.perf_counter()
matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
if not raw:
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
scored = []
lowered_query = query.lower().strip()
for node in nodes:
haystacks = [
str(node.get("id", "")),
str(node.get("content", "")),
json.dumps(node.get("properties", {}), default=str),
]
best_score = 0.0
for haystack in haystacks:
lowered = haystack.lower()
if lowered == lowered_query:
best_score = max(best_score, 1.0)
elif lowered_query in lowered:
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
if best_score > 0:
scored.append({"node": node, "score": round(best_score, 4)})
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
normalized = []
for result in raw:
result_node = result.get("node", {})
node = (
self.normalize_node(result_node)
if "properties" in result_node or "metadata" in result_node or "content" in result_node
else result_node
)
filter_type = filters.get("type") or filters.get("node_type")
if filter_type and node["type"] != filter_type:
continue
min_confidence = self._coerce_float(filters.get("min_confidence"))
node_confidence = self._coerce_float(node["properties"].get("confidence"))
if min_confidence is not None and (
node_confidence is None or node_confidence < min_confidence
):
continue
tags_filter = filters.get("tags")
if tags_filter:
node_tags = node["properties"].get("tags") or []
if isinstance(node_tags, str):
node_tags = [node_tags]
if not set(tags_filter).issubset(set(node_tags)):
normalized_results: List[Dict[str, Any]] = []
with self._lock:
for node_id, score in matches:
raw_node = self.graph.find_node(node_id)
if raw_node is None:
continue
node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
normalized_results.append(
{
"node": self.normalize_node(node_payload),
"score": score,
}
)
normalized.append({"node": node, "score": result.get("score", 0.0)})
return normalized[:limit]
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
logger.debug(
"Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
query,
limit,
diagnostics.get("cache_hit"),
diagnostics.get("path"),
diagnostics.get("candidates"),
duration_ms,
)
return normalized_results[:limit]
def get_stats(self) -> Dict[str, Any]:
with self._lock:
@@ -594,8 +593,51 @@ class GraphSession:
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
with self._lock:
return self.graph.add_nodes(nodes)
added = self.graph.add_nodes(nodes)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
self.rebuild_search_index()
return added
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
with self._lock:
return self.graph.add_edges(edges)
added = self.graph.add_edges(edges)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
self.rebuild_search_index()
return added
def add_node(
self,
node_id: str,
node_type: str,
content: Optional[str] = None,
**properties: Any,
) -> bool:
with self._lock:
added = self.graph.add_node(node_id, node_type, content=content, **properties)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
normalized = self.get_node(node_id)
if normalized is not None:
self._search_index.upsert(normalized)
return added
def add_edge(
self,
source_id: str,
target_id: str,
edge_type: str = "related_to",
weight: float = 1.0,
**properties: Any,
) -> bool:
with self._lock:
added = self.graph.add_edge(
source_id,
target_id,
edge_type=edge_type,
weight=weight,
**properties,
)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
return added
+2
View File
@@ -162,6 +162,7 @@ License: MIT
"""
from .arango_aql_exporter import ArangoAQLExporter
from .distance_exporter import DistanceExporter
from .config import ExportConfig, export_config
try:
@@ -220,6 +221,7 @@ __all__ = [
# Core Exporters
"ArrowExporter",
"ArangoAQLExporter",
"DistanceExporter",
"RDFExporter",
"RDFSerializer",
"RDFValidator",
+226
View File
@@ -0,0 +1,226 @@
"""
Distance-Enriched Export (FR-10)
Exports pairwise node distance metrics hop count, weighted distance,
semantic similarity, distance band, betweenness centrality in CSV or
JSONL format for downstream ML pipelines (GNN training, clustering,
link prediction).
Python API:
exporter = DistanceExporter(graph)
df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"])
exporter.to_csv("distances.csv")
exporter.to_jsonl("distances.jsonl")
"""
import csv
import io
import json
from typing import Any, Dict, List, Optional
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger
logger = get_logger(__name__)
_KG_AVAILABLE = False
try:
from ..kg import PathFinder, SimilarityCalculator, CentralityCalculator
_KG_AVAILABLE = True
except ImportError as exc:
logger.debug("KG components not available; distance exporter will run in reduced mode: %s", exc)
_ALL_COLUMNS = [
"source_id", "source_type", "target_id", "target_type",
"hop_count", "weighted_distance", "semantic_similarity",
"distance_band", "source_betweenness", "target_betweenness",
]
class DistanceExporter:
"""Compute and export pairwise distance metrics for a ContextGraph."""
def __init__(self, graph: Any) -> None:
self.graph = graph
self._path_finder = PathFinder() if _KG_AVAILABLE else None
self._similarity = SimilarityCalculator() if _KG_AVAILABLE else None
self._centrality = CentralityCalculator() if _KG_AVAILABLE else None
def _build_graph_dict(self) -> Dict[str, Any]:
nodes = [
{"id": n.node_id, "type": n.node_type, "content": n.content, "properties": n.properties}
for n in self.graph.nodes.values()
]
edges_raw = getattr(self.graph, "edges", [])
edges = [
{
"id": e.edge_id, "source": e.source_id, "target": e.target_id,
"type": e.edge_type, "weight": e.weight,
}
for e in edges_raw
]
return {"nodes": nodes, "edges": edges}
def _node_type(self, node_id: str) -> str:
node = getattr(self.graph, "nodes", {}).get(node_id)
return getattr(node, "node_type", "") if node else ""
def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]:
if self._centrality is None:
return {}
try:
result = self._centrality.calculate_betweenness_centrality(graph_dict)
return result.get("betweenness", {}) if isinstance(result, dict) else {}
except Exception:
return {}
def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]:
if self._path_finder is None:
return None
try:
result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt)
path = result.get("path", []) if isinstance(result, dict) else (result or [])
return len(path) - 1 if path else None
except Exception:
return None
def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
if self._path_finder is None:
return None
try:
result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt)
if isinstance(result, dict):
return float(result.get("total_weight", len(result.get("path", [])) - 1))
return None
except Exception:
return None
def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
if self._similarity is None:
return None
try:
sim = self._similarity.cosine_similarity(graph_dict, src, tgt)
return float(sim) if isinstance(sim, (int, float)) else None
except Exception:
return None
def compute_pairs(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Compute all pairwise distance metrics and return as a list of dicts."""
include_set = set(include or _ALL_COLUMNS)
graph_dict = self._build_graph_dict()
node_ids = node_subset or list(self.graph.nodes.keys())
betweenness: Dict[str, float] = {}
if "source_betweenness" in include_set or "target_betweenness" in include_set:
betweenness = self._betweenness(graph_dict)
rows = []
for i, src in enumerate(node_ids):
for tgt in node_ids:
if src == tgt:
continue
row: Dict[str, Any] = {}
if "source_id" in include_set:
row["source_id"] = src
if "source_type" in include_set:
row["source_type"] = self._node_type(src)
if "target_id" in include_set:
row["target_id"] = tgt
if "target_type" in include_set:
row["target_type"] = self._node_type(tgt)
hop_count: Optional[int] = None
if "hop_count" in include_set or "distance_band" in include_set:
hop_count = self._hop_distance(graph_dict, src, tgt)
if "hop_count" in include_set:
row["hop_count"] = hop_count
if "weighted_distance" in include_set:
row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt)
if "semantic_similarity" in include_set:
row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt)
if "distance_band" in include_set:
row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant"
if "source_betweenness" in include_set:
row["source_betweenness"] = betweenness.get(src)
if "target_betweenness" in include_set:
row["target_betweenness"] = betweenness.get(tgt)
rows.append(row)
return rows
def to_dataframe(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> Any:
"""Return a pandas DataFrame of pairwise distances."""
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required for to_dataframe()") from exc
rows = self.compute_pairs(include=include, node_subset=node_subset)
return pd.DataFrame(rows)
def to_csv(
self,
path: str,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> None:
"""Write pairwise distances to a CSV file."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
if not rows:
with open(path, "w", newline="", encoding="utf-8") as fh:
fh.write("")
return
fieldnames = list(rows[0].keys())
with open(path, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def to_jsonl(
self,
path: str,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> None:
"""Write pairwise distances to a JSONL file (one JSON object per line)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
with open(path, "w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, default=str) + "\n")
def to_csv_string(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> str:
"""Return CSV as a string (for API responses)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
if not rows:
return ""
buf = io.StringIO()
fieldnames = list(rows[0].keys())
writer = csv.DictWriter(buf, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue()
def to_jsonl_string(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> str:
"""Return JSONL as a string (for API responses)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
return "\n".join(json.dumps(row, default=str) for row in rows)
+63 -40
View File
@@ -328,6 +328,18 @@ class OWLExporter:
lines.append("</rdf:RDF>")
return "\n".join(lines)
@staticmethod
def _escape_ttl_str(value: str) -> str:
"""Escape a string value for safe embedding in a Turtle string literal."""
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
def _ttl_block(self, subject_uri: str, rdf_type: str, predicates: List[str]) -> str:
"""Build a valid Turtle subject block from accumulated predicate strings."""
stmt = f"<{subject_uri}> a {rdf_type}"
for pred in predicates:
stmt += f" ;\n {pred}"
return stmt + " ."
def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str:
"""
Export ontology to OWL Turtle format.
@@ -342,6 +354,7 @@ class OWLExporter:
Returns:
String containing OWL Turtle serialization
"""
esc = self._escape_ttl_str
ontology_uri = ontology.get("uri") or self.ontology_uri
ontology_name = ontology.get("name", "SemanticaOntology")
version = ontology.get("version") or self.version
@@ -357,63 +370,73 @@ class OWLExporter:
lines.append("")
# Ontology declaration
lines.append(f"<{ontology_uri}> a owl:Ontology ;")
lines.append(f' rdfs:label "{ontology_name}" ;')
lines.append(f' owl:versionInfo "{version}" .')
if ontology.get("description"):
lines.append(f' rdfs:comment "{ontology.get("description")}" ;')
onto_predicates = [
f'rdfs:label "{esc(ontology_name)}"',
f'owl:versionInfo "{esc(version)}"',
]
description = ontology.get("description")
if description:
onto_predicates.append(f'rdfs:comment "{esc(description)}"')
lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
lines.append("")
# Classes
classes = ontology.get("classes", [])
for cls in classes:
for cls in ontology.get("classes", []):
class_uri = cls.get("uri") or cls.get("id", "")
class_name = cls.get("name") or cls.get("label", "")
lines.append(f"<{class_uri}> a owl:Class ;")
lines.append(f' rdfs:label "{class_name}" .')
if cls.get("comment"):
lines.append(f' rdfs:comment "{cls.get("comment")}" ;')
if cls.get("subClassOf"):
parent = cls.get("subClassOf")
lines.append(f" rdfs:subClassOf <{parent}> ;")
# Remove trailing semicolon and add period
if lines[-1].endswith(" ;"):
lines[-1] = lines[-1].rstrip(" ;") + " ."
else:
lines.append(" .")
predicates = [f'rdfs:label "{esc(class_name)}"']
comment = cls.get("comment")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
sub_class = cls.get("subClassOf")
if sub_class:
predicates.append(f"rdfs:subClassOf <{sub_class}>")
equiv = cls.get("equivalentClass")
if equiv:
predicates.append(f"owl:equivalentClass <{equiv}>")
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
lines.append("")
# Object properties
object_properties = ontology.get("object_properties", [])
for prop in object_properties:
for prop in ontology.get("object_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
prop_name = prop.get("name") or prop.get("label", "")
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
lines.append(f' rdfs:label "{prop_name}" .')
if prop.get("domain"):
domain = prop.get("domain")
predicates = [f'rdfs:label "{esc(prop_name)}"']
comment = prop.get("comment")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
domain = prop.get("domain")
if domain:
if isinstance(domain, list):
for d in domain:
lines.append(f" rdfs:domain <{d}> ;")
predicates.append(f"rdfs:domain <{d}>")
else:
lines.append(f" rdfs:domain <{domain}> ;")
if prop.get("range"):
range_val = prop.get("range")
predicates.append(f"rdfs:domain <{domain}>")
range_val = prop.get("range")
if range_val:
if isinstance(range_val, list):
for r in range_val:
lines.append(f" rdfs:range <{r}> ;")
predicates.append(f"rdfs:range <{r}>")
else:
lines.append(f" rdfs:range <{range_val}> ;")
predicates.append(f"rdfs:range <{range_val}>")
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
lines.append("")
if lines[-1].endswith(" ;"):
lines[-1] = lines[-1].rstrip(" ;") + " ."
# Data properties
for prop in ontology.get("data_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
prop_name = prop.get("name") or prop.get("label", "")
predicates = [f'rdfs:label "{esc(prop_name)}"']
comment = prop.get("comment")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
domain = prop.get("domain")
if domain:
predicates.append(f"rdfs:domain <{domain}>")
range_type = prop.get("range")
if range_type:
predicates.append(f"rdfs:range xsd:{range_type}")
lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
lines.append("")
return "\n".join(lines)
+38 -19
View File
@@ -104,7 +104,8 @@ class PathFinder:
source: str,
target: str,
weight_attribute: str = "weight",
default_weight: float = 1.0
default_weight: float = 1.0,
directed: bool = True
) -> List[str]:
"""
Find shortest path using Dijkstra's algorithm.
@@ -125,32 +126,34 @@ class PathFinder:
"""
try:
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
# Validate nodes exist
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
traversal_graph = graph if directed else self._make_undirected_view(graph)
# Dijkstra's algorithm
distances = {source: 0.0}
previous = {}
priority_queue = [(0.0, source)]
visited = set()
while priority_queue:
current_distance, current_node = heapq.heappop(priority_queue)
if current_node in visited:
continue
visited.add(current_node)
if current_node == target:
break
# Explore neighbors
for neighbor, edge_data in self._get_neighbors(graph, current_node):
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
if neighbor in visited:
continue
@@ -350,44 +353,48 @@ class PathFinder:
self,
graph: Any,
source: str,
target: str
target: str,
directed: bool = True
) -> List[str]:
"""
Find shortest path using BFS (unweighted).
Args:
graph: Graph object (NetworkX or similar)
source: Source node ID
target: Target node ID
directed: If False, treat the graph as undirected for traversal
Returns:
List of node IDs representing the shortest path
Raises:
ValueError: If source or target not found
"""
try:
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
# Validate nodes exist
if not self._node_exists(graph, source):
raise ValueError(f"Source node {source} not found")
if not self._node_exists(graph, target):
raise ValueError(f"Target node {target} not found")
traversal_graph = graph if directed else self._make_undirected_view(graph)
# BFS algorithm
queue = deque([(source, [source])])
visited = {source}
while queue:
current, path = queue.popleft()
if current == target:
self.logger.info(f"Found BFS path of length {len(path)}")
return path
# Explore neighbors
for neighbor, _ in self._get_neighbors(graph, current):
for neighbor, _ in self._get_neighbors(traversal_graph, current):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, path + [neighbor]))
@@ -564,6 +571,18 @@ class PathFinder:
return False
return False
def _make_undirected_view(self, graph: Any) -> Any:
"""Return an undirected view of the graph for bidirectional traversal.
For NetworkX directed graphs this calls ``to_undirected()``, which
preserves all edge attributes. For graph types that have no such
method the original object is returned as a fallback callers that
already expose undirected neighbors will still work correctly.
"""
if hasattr(graph, "to_undirected"):
return graph.to_undirected()
return graph
def _get_neighbors(self, graph: Any, node: str) -> List[Tuple[str, Any]]:
"""Get neighbors of a node with edge data."""
neighbors = []
+6 -3
View File
@@ -397,6 +397,7 @@ class BaseProvider:
create_kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**create_kwargs)
verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
if verbose_mode:
import sys
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
@@ -939,20 +940,22 @@ class DeepSeekProvider(BaseProvider):
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
super().__init__(**kwargs)
self.api_key = api_key or config.get_api_key("deepseek")
self.base_url = "https://api.deepseek.com/v1"
self.model = model
self.base_url = "https://api.deepseek.com/v1"
self.client = None
self._init_client()
def _init_client(self):
try:
import deepseek # type: ignore[import-untyped]
from openai import OpenAI
if self.api_key:
self.client = deepseek.Client(api_key=self.api_key)
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
except (ImportError, OSError):
self.client = None
self.logger.warning(
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
"openai library not installed. Install with: pip install semantica[llm-openai]"
)
def is_available(self) -> bool:
+15 -3
View File
@@ -188,10 +188,22 @@ async def serve_spa(full_path: str):
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API route not found")
# Root path — serve index.html if built, otherwise a welcome JSON response
if full_path in ("", "/"):
index_file = STATIC_DIR / "index.html"
if index_file.is_file():
return FileResponse(index_file)
return JSONResponse({
"name": "Semantica Knowledge Explorer",
"version": __version__,
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
"docs": "/docs",
"health": "/health",
})
normalized_path = os.path.normpath(full_path)
if (
normalized_path in ("", ".")
or os.path.isabs(normalized_path)
os.path.isabs(normalized_path)
or normalized_path == ".."
or normalized_path.startswith(".." + os.sep)
):
@@ -200,7 +212,7 @@ async def serve_spa(full_path: str):
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
safe_rel_path = normalized_path.lstrip("/\\")
rel_parts = Path(safe_rel_path).parts
if any(part in ("", ".", "..") for part in rel_parts):
if any(part in (".", "..") for part in rel_parts):
raise HTTPException(status_code=400, detail="Invalid path")
static_dir_resolved = STATIC_DIR.resolve()
+22
View File
@@ -562,3 +562,25 @@ def retry_on_error(
return wrapper
return decorator
def classify_path_distance(hop_count: int) -> str:
"""Classify a path hop count into a human-readable distance band.
Bands:
"direct" 01 hops (single edge or self)
"near" 23 hops (closely related)
"mid-range" 46 hops (reachable but separated)
"distant" 7+ hops (weakly coupled)
This is the single source of truth for distance-band thresholds used by
both the Explorer API (PathResponse.distance_band) and the KGVisualizer
(highlight_path edge styling).
"""
if hop_count <= 1:
return "direct"
if hop_count <= 3:
return "near"
if hop_count <= 6:
return "mid-range"
return "distant"
+81 -20
View File
@@ -61,6 +61,7 @@ try:
except Exception: # pragma: no cover
_KnowledgeGraph = None # type: ignore[assignment,misc]
from ..utils.helpers import classify_path_distance
from ..utils.progress_tracker import get_progress_tracker
from .utils.color_schemes import ColorPalette, ColorScheme
from .utils.export_formats import (
@@ -191,6 +192,7 @@ class KGVisualizer:
node_color_by: str = "type",
node_size_by: Optional[str] = None,
hover_data: Optional[List[str]] = None,
highlight_path: Optional[List[str]] = None,
**options,
) -> Optional[Any]:
"""
@@ -212,6 +214,9 @@ class KGVisualizer:
node_color_by: Property to map to node color (default: "type")
node_size_by: Property to map to node size (default: fixed)
hover_data: List of properties to show in hover tooltip
highlight_path: Optional ordered list of node IDs forming a path to
highlight with distance-aware edge styling (opacity and stroke
weight reflect hop count along the path).
**options: Additional visualization options
Returns:
@@ -261,13 +266,14 @@ class KGVisualizer:
tracking_id, message="Generating visualization..."
)
result = self._visualize_network_plotly(
nodes,
edges,
output,
file_path,
nodes,
edges,
output,
file_path,
node_color_by=node_color_by,
node_size_by=node_size_by,
hover_data=hover_data,
highlight_path=highlight_path,
**options
)
@@ -564,6 +570,21 @@ class KGVisualizer:
return edges
@staticmethod
def _path_edge_style(distance_band: str) -> Tuple[float, float]:
"""Return (opacity, width) for a path edge based on its distance band.
Bands come from ``classify_path_distance`` in ``utils.helpers`` the
single source of truth for hop-count thresholds.
"""
if distance_band == "direct":
return (1.0, 4.0)
if distance_band == "near":
return (0.85, 3.0)
if distance_band == "mid-range":
return (0.6, 2.0)
return (0.35, 1.5) # "distant"
def _visualize_network_plotly(
self,
nodes: List[Dict[str, Any]],
@@ -573,6 +594,7 @@ class KGVisualizer:
node_color_by: str = "type",
node_size_by: Optional[str] = None,
hover_data: Optional[List[str]] = None,
highlight_path: Optional[List[str]] = None,
**options,
) -> Optional[Any]:
"""Create Plotly network visualization."""
@@ -675,47 +697,73 @@ class KGVisualizer:
node_text.append(text)
# Prepare edge traces
edge_x = []
edge_y = []
# Build path edge lookup for highlight_path support
path_edge_set: set = set()
path_distance_band = "direct"
if highlight_path and len(highlight_path) >= 2:
path_hop_count = len(highlight_path) - 1
path_distance_band = classify_path_distance(path_hop_count)
# Only add the directed edges that actually form the path (A→B, not B→A).
# Adding the reverse would incorrectly highlight unrelated back-edges.
for i in range(path_hop_count):
path_edge_set.add((highlight_path[i], highlight_path[i + 1]))
# Warn if any path node has no layout position (silent highlight failure).
missing = [n for n in highlight_path if n not in pos]
if missing:
self.logger.warning(
"highlight_path contains node IDs not found in the graph: %s",
missing,
)
path_opacity, path_width = self._path_edge_style(path_distance_band)
# Prepare edge traces — split into background (non-path) and path edges
edge_x: List = []
edge_y: List = []
path_edge_x: List = []
path_edge_y: List = []
# Prepare edge label traces and annotations (for arrows)
edge_label_x = []
edge_label_y = []
edge_label_text = []
annotations = []
# Limit detailed edge rendering for performance if graph is too large
show_detailed_edges = len(edges) < 500
for edge in edges:
source_pos = pos.get(edge["source"])
target_pos = pos.get(edge["target"])
if source_pos and target_pos:
x0, y0 = source_pos
x1, y1 = target_pos
edge_x.extend([x0, x1, None])
edge_y.extend([y0, y1, None])
is_path_edge = (edge["source"], edge["target"]) in path_edge_set
if is_path_edge:
path_edge_x.extend([x0, x1, None])
path_edge_y.extend([y0, y1, None])
else:
edge_x.extend([x0, x1, None])
edge_y.extend([y0, y1, None])
if show_detailed_edges:
# Calculate midpoint for label
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
if edge.get("label"):
edge_label_x.append(mx)
edge_label_y.append(my)
edge_label_text.append(edge["label"])
# Add arrow annotation
# Adjust arrow to point slightly before the node to avoid overlap with node marker
# This is approximate; precise calculation requires node size
annotations.append(
dict(
ax=x0, ay=y0, axref='x', ayref='y',
x=x1, y=y1, xref='x', yref='y',
arrowhead=2, arrowsize=1, arrowwidth=1,
arrowcolor="#888", opacity=0.6,
standoff=15 # Distance from target node
standoff=15
)
)
@@ -728,9 +776,22 @@ class KGVisualizer:
showlegend=False,
opacity=0.5
)
traces = [edge_trace]
# Overlay highlighted path edges with distance-aware styling
if path_edge_x:
path_trace = go.Scatter(
x=path_edge_x,
y=path_edge_y,
line=dict(width=path_width, color="#e05c00"),
hoverinfo="none",
mode="lines",
showlegend=False,
opacity=path_opacity,
)
traces.append(path_trace)
if show_detailed_edges and edge_label_text:
edge_label_trace = go.Scatter(
x=edge_label_x,
+212
View File
@@ -0,0 +1,212 @@
"""Targeted regression tests for all 13 Qodo review fixes on the Distance Intelligence PR."""
import re
import inspect
# ── bug_003: include_distance_metadata=False is the backward-compat default ───
def test_bug003_metadata_absent_by_default():
from semantica.context.context_graph import ContextGraph
g = ContextGraph()
g.add_node("A", "test")
g.add_node("B", "test")
g.add_edge("A", "B", "related")
neighbors = g.get_neighbors("A")
assert len(neighbors) == 1
assert "hop" in neighbors[0]
assert "distance_band" not in neighbors[0], (
f"distance_band should be absent by default; got keys: {list(neighbors[0].keys())}"
)
assert "confidence_decay" not in neighbors[0]
assert "path_to_anchor" not in neighbors[0]
def test_bug003_metadata_present_with_flag():
from semantica.context.context_graph import ContextGraph
g = ContextGraph()
g.add_node("A", "test")
g.add_node("B", "test")
g.add_edge("A", "B", "related")
neighbors = g.get_neighbors("A", include_distance_metadata=True)
assert len(neighbors) == 1
assert "distance_band" in neighbors[0]
assert "confidence_decay" in neighbors[0]
assert "path_to_anchor" in neighbors[0]
def test_bug003_get_neighbor_distances_still_works():
from semantica.context.context_graph import ContextGraph
g = ContextGraph()
g.add_node("A", "test")
g.add_node("B", "test")
g.add_edge("A", "B", "related", weight=0.9)
nd = g.get_neighbor_distances("A")
assert len(nd) == 1
assert nd[0]["distance_band"] == "direct"
assert abs(nd[0]["confidence_decay"] - 0.9) < 1e-9
assert "path_to_anchor" in nd[0]
# ── bug_004: weakest_link standardized to edge_weight key ─────────────────────
def test_bug004_weakest_link_uses_edge_weight_key():
from semantica.context.context_graph import ContextGraph
from semantica.context.causal_analyzer import CausalChainAnalyzer
g = ContextGraph()
g.add_node("A", "decision")
g.add_node("B", "decision")
g.add_node("C", "decision")
g.add_edge("A", "B", "causes", weight=0.8)
g.add_edge("B", "C", "causes", weight=0.5)
analyzer = CausalChainAnalyzer(g)
report = analyzer.interpret_causal_distance("A", "C")
wl = report.get("weakest_link")
assert wl is not None, "weakest_link must be set for a 2-hop causal path"
assert "edge_weight" in wl, f"Expected edge_weight key, got: {list(wl.keys())}"
assert "weight" not in wl, f"Old key 'weight' should be absent; got: {list(wl.keys())}"
assert wl["edge_weight"] == 0.5
def test_bug004_causal_distance_report_schema_validates():
from semantica.explorer.schemas import CausalDistanceReport
report = CausalDistanceReport(
source_id="A",
target_id="C",
causal_path=["A", "B", "C"],
causal_hop_count=2,
intermediate_decisions=["B"],
confidence_decay=0.4,
weakest_link={"source": "A", "target": "B", "edge_weight": 0.5},
interpretation="Test path",
)
assert report.weakest_link["edge_weight"] == 0.5
# ── qual_003: _distance_band static methods removed; classify_path_distance used ─
def test_qual003_distance_band_removed_from_causal_analyzer():
from semantica.context.causal_analyzer import CausalChainAnalyzer
assert not hasattr(CausalChainAnalyzer, "_distance_band")
ca_src = inspect.getsource(CausalChainAnalyzer)
assert "def _distance_band" not in ca_src
assert "classify_path_distance" in ca_src
def test_qual003_distance_band_removed_from_agent_context():
import semantica.context.agent_context as ac_mod
ac_src = inspect.getsource(ac_mod)
assert "def _distance_band" not in ac_src
assert "classify_path_distance" in ac_src
# ── bug_005: timedelta arithmetic — no timetuple reconstruction ───────────────
def test_bug005_no_timetuple_hack_in_distance_history():
from semantica.explorer.routes import temporal
src = inspect.getsource(temporal.distance_history)
assert "timetuple" not in src, "Old timetuple hack should be gone"
assert "__import__" not in src, "Dynamic import hack should be gone"
assert "timedelta(seconds" in src
# ── sec_001: node_subset capped at 200 ────────────────────────────────────────
def test_sec001_node_subset_limit_constant_exists():
from semantica.explorer.routes.export_import import _DISTANCE_EXPORT_MAX_NODES
assert _DISTANCE_EXPORT_MAX_NODES == 200
def test_sec001_export_endpoint_validates_subset_size():
from semantica.explorer.routes import export_import
src = inspect.getsource(export_import.export_distance_enriched)
assert "_DISTANCE_EXPORT_MAX_NODES" in src
assert "status_code=413" in src
# ── sec_002: distance matrix upper-triangle only ──────────────────────────────
def test_sec002_distance_matrix_upper_triangle_loop():
from semantica.explorer.routes import graph
src = inspect.getsource(graph.distance_matrix)
assert "range(i + 1, n)" in src, "Should use upper-triangle loop"
assert "matrix[j][i]" in src, "Should mirror lower triangle"
# ── bug_006: O(L) edge weight index built once ────────────────────────────────
def test_bug006_edge_weight_index_built_once():
from semantica.explorer.routes import graph
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
assert "edge_weight_index" in src
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
# ── bug_007: original result id not overwritten ───────────────────────────────
def test_bug007_original_id_not_overwritten():
from semantica.context import agent_context
src = inspect.getsource(agent_context.AgentContext._apply_proximity_metadata)
assert (
'"graph_node_id": result_id' in src
or "'graph_node_id': result_id" in src
)
assert '"id": result_id' not in src, "id should not be overwritten by result_id"
# ── qual_002: no bare except:pass in enrichment blocks ───────────────────────
def test_qual002_no_bare_except_pass_in_find_path():
from semantica.explorer.routes import graph
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
assert "logger.debug" in src
# ── TypeScript fixes — checked via raw file reads ─────────────────────────────
TS_BEHAVIOR = (
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
r"\GraphWorkspace\behaviors\pathHighlightBehavior.ts"
)
TS_WORKSPACE = (
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
r"\GraphWorkspace\GraphWorkspace.tsx"
)
def test_bug008_sweep_generation_counter():
with open(TS_BEHAVIOR, encoding="utf-8") as fh:
src = fh.read()
assert "sweepGeneration" in src, "Generation counter variable must exist"
assert "gen !== sweepGeneration" in src, "Stale-callback guard must exist"
assert "sweepGeneration++" in src, "Counter must be incremented on cancel"
def test_bug001_semantic_neighborhood_uses_top_k():
with open(TS_WORKSPACE, encoding="utf-8") as fh:
src = fh.read()
assert (
"top_k=50" in src or 'top_k: "50"' in src
), "Should use top_k (not limit) to match backend param"
idx = src.find("semantic-neighborhood?")
snippet = src[idx: idx + 100]
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
def test_bug002_semantic_neighborhood_response_type_complete():
with open(TS_WORKSPACE, encoding="utf-8") as fh:
src = fh.read()
assert "anchor_node: string" in src
assert "hop_distance?" in src
def test_qual001_ego_heatmap_merged_into_single_effect():
with open(TS_WORKSPACE, encoding="utf-8") as fh:
src = fh.read()
assert "egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId" in src, (
"Combined dep array must be present"
)
# The old separate dep arrays must not exist
assert "], [egoModeEnabled, egoMaxHops, selectedNodeId]" not in src
assert "], [heatmapEnabled, selectedNodeId]" not in src
@@ -0,0 +1,97 @@
from semantica.context.context_graph import ContextGraph
def test_get_neighbor_distances_tracks_path_decay_and_band():
graph = ContextGraph(advanced_analytics=False)
graph.add_node("A", "entity", "Anchor")
graph.add_node("B", "entity", "Bridge")
graph.add_node("C", "decision", "Decision")
graph.add_edge("A", "B", "influences", weight=0.9)
graph.add_edge("B", "C", "influences", weight=0.7)
neighbors = graph.get_neighbor_distances("A", hops=2, min_confidence=0.5)
c_neighbor = next(item for item in neighbors if item["id"] == "C")
assert c_neighbor["hop"] == 2
assert c_neighbor["distance_band"] == "near"
assert c_neighbor["confidence_decay"] == 0.63
assert c_neighbor["path_to_anchor"] == ["A", "B", "C"]
def test_trace_decision_causality_returns_auditable_chain_dicts():
graph = ContextGraph(advanced_analytics=False)
first = graph.record_decision(
category="risk",
scenario="Approve initial risk policy",
reasoning="Baseline risk controls look sound",
outcome="approved",
confidence=0.8,
entities=["account_123"],
)
second = graph.record_decision(
category="risk",
scenario="Approve follow-up risk exception",
reasoning="Prior account controls still apply",
outcome="approved",
confidence=0.9,
entities=["account_123"],
)
graph._decisions[first]["timestamp"] = 1
graph._decisions[second]["timestamp"] = 2
chains = graph.trace_decision_causality(second, max_depth=2)
assert chains
assert chains[0]["hop_count"] == 1
assert chains[0]["distance_band"] == "direct"
assert chains[0]["weakest_link"]["from"] == first
assert chains[0]["hops"][0]["to"] == second
assert "confidence" in chains[0]["interpretation"]
assert list(chains[0])[0]["from"] == first
def test_analyze_decision_influence_exposes_score_breakdown():
graph = ContextGraph(advanced_analytics=False)
source = graph.record_decision(
category="loan",
scenario="Approve secured loan",
reasoning="Collateral and income verified",
outcome="approved",
confidence=0.9,
entities=["borrower_1"],
)
graph.record_decision(
category="loan",
scenario="Review related refinance",
reasoning="Same borrower and collateral",
outcome="review",
confidence=0.8,
entities=["borrower_1"],
)
result = graph.analyze_decision_influence(source)
assert result["influence_scores"]
score = result["influence_scores"][0]
assert set(score["score_breakdown"]) == {
"entity_overlap",
"category_match",
"temporal_proximity",
}
assert score["is_direct"] is True
def test_cross_graph_path_traverses_link_boundary():
left = ContextGraph(advanced_analytics=False)
right = ContextGraph(advanced_analytics=False)
left.add_node("A", "entity", "Left")
right.add_node("B", "entity", "Right")
left.link_graph(right, "A", "B")
path = left.cross_graph_path("A", right, "B")
assert path["reachable"] is True
assert path["hop_count"] == 1
assert path["cross_graph_links_used"] == 1
assert path["distance_band"] == "direct"
assert path["path"] == [(left.graph_id, "A"), (right.graph_id, "B")]
+368
View File
@@ -4,6 +4,7 @@ import json
from pathlib import Path
import uuid
import networkx as nx
import pytest
from semantica.context.context_graph import ContextGraph
@@ -35,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30)
graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60)
graph.add_node(
"metformin",
node_type="drug",
content="Metformin",
aliases=["Glucophage"],
confidence="0.97",
tags=["drug", "featured"],
x=22,
y=33,
)
graph.add_node(
"decision_1",
node_type="decision",
@@ -243,6 +254,74 @@ class TestSearchAndStats:
assert payload["total"] >= 1
assert all(item["node"]["type"] == "language" for item in payload["results"])
def test_search_exact_and_prefix(self, client):
exact_response = client.post(
"/api/graph/search",
json={"query": "Metformin", "limit": 5},
)
assert exact_response.status_code == 200
exact_payload = exact_response.json()
assert exact_payload["results"][0]["node"]["id"] == "metformin"
prefix_response = client.post(
"/api/graph/search",
json={"query": "metf", "limit": 5},
)
assert prefix_response.status_code == 200
prefix_payload = prefix_response.json()
assert any(item["node"]["id"] == "metformin" for item in prefix_payload["results"])
def test_search_filters_and_cache_stability(self, client):
body = {
"query": "framework",
"filters": {"type": "decision", "min_confidence": 0.8},
"limit": 5,
}
first_response = client.post("/api/graph/search", json=body)
second_response = client.post("/api/graph/search", json=body)
assert first_response.status_code == 200
assert second_response.status_code == 200
assert first_response.json() == second_response.json()
results = first_response.json()["results"]
assert [item["node"]["id"] for item in results] == ["decision_1"]
def test_search_sees_new_nodes_after_mutation(self, client):
session = client.app.state.session
assert session.add_node(
"metformin_hcl",
"drug",
content="Metformin Hydrochloride",
aliases=["Glucophage XR"],
confidence="0.93",
)
response = client.post(
"/api/graph/search",
json={"query": "glucophage", "limit": 10},
)
assert response.status_code == 200
result_ids = [item["node"]["id"] for item in response.json()["results"]]
assert "metformin" in result_ids
assert "metformin_hcl" in result_ids
def test_search_secondary_scan_fallback_matches_non_curated_properties(self, client):
session = client.app.state.session
assert session.add_node(
"fallback_node",
"entity",
content="Alpha",
description="rareterm",
)
response = client.post(
"/api/graph/search",
json={"query": "rareterm", "limit": 10},
)
assert response.status_code == 200
result_ids = [item["node"]["id"] for item in response.json()["results"]]
assert "fallback_node" in result_ids
def test_stats(self, client):
response = client.get("/api/graph/stats")
assert response.status_code == 200
@@ -638,3 +717,292 @@ class TestGenericGraphFileLoading:
assert repeat.status_code == 200
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
assert repeat_ids == ["edge-alpha", "edge-beta"]
# ---------------------------------------------------------------------------
# Bidirectional path-finding tests (issue #469)
# ---------------------------------------------------------------------------
def _make_path_session() -> GraphSession:
"""Return a GraphSession whose build_graph_dict yields an nx.DiGraph with A→B only.
GraphSession wraps a ContextGraph (required by create_app), but we patch
build_graph_dict so PathFinder receives an actual NetworkX DiGraph the
graph type the Explorer is designed to traverse for path queries.
"""
cg = ContextGraph(advanced_analytics=False)
cg.add_node("A", node_type="entity", content="Node A")
cg.add_node("B", node_type="entity", content="Node B")
cg.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34")
cg.add_node("disease/term:1", node_type="disease", content="Slash target")
cg.add_edge("A", "B", edge_type="connects")
cg.add_edge("gene/protein:6164", "disease/term:1", edge_type="connects")
session = GraphSession(cg)
# Patch build_graph_dict to return the directed NetworkX graph that
# PathFinder needs. The ContextGraph dict format is not traversable by
# PathFinder; this mimics how a KG-backed session would expose the graph.
digraph = nx.DiGraph()
digraph.add_edge("A", "B")
digraph.add_edge("gene/protein:6164", "disease/term:1")
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
return session
@pytest.fixture
def path_client():
session = _make_path_session()
app = create_app(session=session)
with TestClient(app) as c:
yield c
class TestBidirectionalPathRoute:
"""API-level tests for directed=true/false on GET /api/graph/node/{id}/path."""
# ------------------------------------------------------------------
# directed=true (default) — existing directed-only behaviour
# ------------------------------------------------------------------
def test_directed_true_forward_path_found(self, path_client):
"""A→B exists: forward query with directed=true must succeed."""
resp = path_client.get("/api/graph/node/A/path?target=B&directed=true")
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["A", "B"]
assert body["directed"] is True
def test_directed_true_reverse_returns_404(self, path_client):
"""Only A→B exists: reverse query with directed=true must return 404."""
resp = path_client.get("/api/graph/node/B/path?target=A&directed=true")
assert resp.status_code == 404
def test_default_param_reverse_returns_404(self, path_client):
"""Omitting directed= must preserve current directed behaviour (404 for reverse)."""
resp = path_client.get("/api/graph/node/B/path?target=A")
assert resp.status_code == 404
# ------------------------------------------------------------------
# directed=false — new undirected traversal
# ------------------------------------------------------------------
def test_directed_false_reverse_path_found(self, path_client):
"""directed=false must find B→A even though only A→B exists."""
resp = path_client.get("/api/graph/node/B/path?target=A&directed=false")
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["B", "A"]
assert body["directed"] is False
def test_query_path_route_supports_slash_node_ids(self, path_client):
"""Query-param path route must support arbitrary graph ids with slashes."""
resp = path_client.get(
"/api/graph/path",
params={
"source": "gene/protein:6164",
"target": "disease/term:1",
"algorithm": "dijkstra",
},
)
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["gene/protein:6164", "disease/term:1"]
assert body["source"] == "gene/protein:6164"
assert body["target"] == "disease/term:1"
def test_directed_false_forward_path_found(self, path_client):
"""directed=false must not break the natural A→B direction."""
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["A", "B"]
assert body["directed"] is False
# ------------------------------------------------------------------
# Algorithm variants
# ------------------------------------------------------------------
def test_dijkstra_directed_false_reverse(self, path_client):
resp = path_client.get(
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=false"
)
assert resp.status_code == 200
body = resp.json()
assert body["path"] == ["B", "A"]
assert body["algorithm"] == "dijkstra"
assert body["directed"] is False
def test_dijkstra_directed_true_reverse_returns_404(self, path_client):
resp = path_client.get(
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=true"
)
assert resp.status_code == 404
# ------------------------------------------------------------------
# PathResponse schema
# ------------------------------------------------------------------
def test_response_schema_includes_directed_field(self, path_client):
"""PathResponse must always include the directed field."""
resp = path_client.get("/api/graph/node/A/path?target=B")
assert resp.status_code == 200
body = resp.json()
assert "directed" in body
def test_response_directed_reflects_query_param(self, path_client):
resp_true = path_client.get("/api/graph/node/A/path?target=B&directed=true")
resp_false = path_client.get("/api/graph/node/A/path?target=B&directed=false")
assert resp_true.json()["directed"] is True
assert resp_false.json()["directed"] is False
# ------------------------------------------------------------------
# hop_count and distance_band — issue #472
# ------------------------------------------------------------------
def test_response_includes_hop_count_and_distance_band(self, path_client):
"""PathResponse must include hop_count and distance_band fields."""
resp = path_client.get("/api/graph/node/A/path?target=B")
assert resp.status_code == 200
body = resp.json()
assert "hop_count" in body
assert "distance_band" in body
def test_one_hop_path_is_direct(self, path_client):
"""A single-edge path (1 hop) must return distance_band='direct'."""
resp = path_client.get("/api/graph/node/A/path?target=B")
assert resp.status_code == 200
body = resp.json()
assert body["hop_count"] == 1
assert body["distance_band"] == "direct"
# ---------------------------------------------------------------------------
# _classify_distance unit tests — issue #472
# ---------------------------------------------------------------------------
from semantica.utils.helpers import classify_path_distance
class _FakeSimilarity:
"""Minimal similarity stub shared by slash-safe distance route tests.
Expects embeddings keyed on 'gene/protein:6164' with query vector [1, 0, 0]
and returns a single neighbor result. Tests that need different behaviour
can assign a lambda to instance.find_most_similar after construction.
"""
def find_most_similar(self, embeddings, query_embedding, top_k=10):
assert "gene/protein:6164" in embeddings
assert query_embedding == [1.0, 0.0, 0.0]
return [("disease/term:1", 0.74)]
def _make_slash_node_session(*, with_embeddings: bool = True) -> GraphSession:
"""Return an isolated GraphSession with slash-containing node IDs."""
graph = ContextGraph(advanced_analytics=False)
kwargs = {"embedding": [1.0, 0.0, 0.0]} if with_embeddings else {}
graph.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34", **kwargs)
graph.add_node(
"disease/term:1",
node_type="disease",
content="Slash target",
**({"embedding": [0.7, 0.2, 0.1]} if with_embeddings else {}),
)
session = GraphSession(graph)
session._similarity = _FakeSimilarity()
return session
class TestSlashSafeDistanceRoutes:
def test_query_semantic_neighborhood_supports_slash_node_ids(self):
session = _make_slash_node_session(with_embeddings=True)
app = create_app(session=session)
with TestClient(app) as test_client:
resp = test_client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:6164", "top_k": 50},
)
assert resp.status_code == 200
body = resp.json()
assert body["anchor_node"] == "gene/protein:6164"
assert body["neighbors"][0]["id"] == "disease/term:1"
assert body["neighbors"][0]["similarity"] == 0.74
def test_legacy_semantic_neighborhood_still_works_for_simple_ids(self):
"""Legacy path-segment route must still return 200 for slash-free node IDs."""
graph = ContextGraph(advanced_analytics=False)
graph.add_node(
"semantic_anchor",
node_type="entity",
content="Semantic anchor",
embedding=[1.0, 0.0, 0.0],
)
graph.add_node(
"semantic_neighbor",
node_type="entity",
content="Semantic neighbor",
embedding=[0.8, 0.2, 0.0],
)
session = GraphSession(graph)
fake = _FakeSimilarity()
fake.find_most_similar = (
lambda embeddings, query_embedding, top_k=10: [("semantic_neighbor", 0.8)]
)
session._similarity = fake
app = create_app(session=session)
with TestClient(app) as test_client:
resp = test_client.get(
"/api/graph/node/semantic_anchor/semantic-neighborhood?top_k=10"
)
assert resp.status_code == 200
assert resp.json()["anchor_node"] == "semantic_anchor"
def test_query_semantic_neighborhood_missing_node_returns_404(self, client):
resp = client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:missing"},
)
assert resp.status_code == 404
def test_query_semantic_neighborhood_without_embeddings_returns_503(self):
session = _make_slash_node_session(with_embeddings=False)
app = create_app(session=session)
with TestClient(app) as test_client:
resp = test_client.get(
"/api/graph/semantic-neighborhood",
params={"node_id": "gene/protein:6164", "top_k": 50},
)
assert resp.status_code == 503
class TestClassifyDistance:
"""Unit tests covering all four band boundaries."""
def test_zero_hops_is_direct(self):
assert classify_path_distance(0) == "direct"
def test_one_hop_is_direct(self):
assert classify_path_distance(1) == "direct"
def test_two_hops_is_near(self):
assert classify_path_distance(2) == "near"
def test_three_hops_is_near(self):
assert classify_path_distance(3) == "near"
def test_four_hops_is_mid_range(self):
assert classify_path_distance(4) == "mid-range"
def test_six_hops_is_mid_range(self):
assert classify_path_distance(6) == "mid-range"
def test_seven_hops_is_distant(self):
assert classify_path_distance(7) == "distant"
def test_large_hop_count_is_distant(self):
assert classify_path_distance(20) == "distant"
+74
View File
@@ -0,0 +1,74 @@
"""Unit tests for explorer provenance route helpers."""
from types import SimpleNamespace
from semantica.explorer.routes.provenance import _build_provenance, _render_markdown
def _make_session_with_chain() -> SimpleNamespace:
"""Build a minimal session-like object for Source -> Intermediate -> node_id."""
nodes = {
"Source": SimpleNamespace(node_type="entity", content="Source"),
"Intermediate": SimpleNamespace(node_type="entity", content="Intermediate"),
"node_id": SimpleNamespace(node_type="entity", content="Target"),
}
edges = [
SimpleNamespace(source_id="Source", target_id="Intermediate", edge_type="related_to"),
SimpleNamespace(source_id="Intermediate", target_id="node_id", edge_type="related_to"),
]
graph = SimpleNamespace(nodes=nodes, edges=edges)
return SimpleNamespace(graph=graph)
def test_build_provenance_direction_classification_chain():
session = _make_session_with_chain()
data = _build_provenance(session, "node_id")
node_ids = {node["id"] for node in data["nodes"]}
assert "Source" in node_ids
assert "Intermediate" in node_ids
edge_by_pair = {(edge["source"], edge["target"]): edge for edge in data["edges"]}
assert edge_by_pair[("Intermediate", "node_id")]["direction"] == "upstream"
assert edge_by_pair[("Source", "Intermediate")]["direction"] != "downstream"
def test_render_markdown_groups_edges_by_direction():
report = {
"node_id": "node_id",
"label": "Target",
"type": "entity",
"properties": {},
"lineage": {
"nodes": [
{"id": "Source", "prov_type": "Entity", "label": "Source"},
{"id": "Intermediate", "prov_type": "Entity", "label": "Intermediate"},
{"id": "node_id", "prov_type": "Entity", "label": "Target"},
],
"edges": [
{
"id": "Intermediate-node_id",
"source": "Intermediate",
"target": "node_id",
"label": "related_to",
"direction": "upstream",
},
{
"id": "Source-Intermediate",
"source": "Source",
"target": "Intermediate",
"label": "related_to",
"direction": "lateral",
},
],
},
}
markdown = _render_markdown(report)
assert "## Upstream" in markdown
assert "## Lateral" in markdown
assert "`Intermediate` -[related_to]-> `node_id`" in markdown
assert "`Source` -[related_to]-> `Intermediate`" in markdown
+549
View File
@@ -0,0 +1,549 @@
"""Tests for OWLExporter._export_owl_turtle fixes (issue #478).
Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended
after a closing period).
Bug 2: data_properties silently dropped from Turtle output.
"""
import pytest
from semantica.export import OWLExporter
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def exporter():
return OWLExporter()
@pytest.fixture
def full_ontology():
return {
"uri": "http://example.org/onto",
"name": "TestOntology",
"description": "A test ontology",
"classes": [
{
"uri": "http://example.org/Person",
"name": "Person",
},
{
"uri": "http://example.org/Employee",
"name": "Employee",
"comment": "A person who is employed",
"subClassOf": "http://example.org/Person",
},
{
"uri": "http://example.org/Manager",
"name": "Manager",
"subClassOf": "http://example.org/Employee",
"equivalentClass": "http://example.org/Supervisor",
},
],
"object_properties": [
{
"uri": "http://example.org/worksFor",
"name": "worksFor",
"domain": "http://example.org/Employee",
"range": "http://example.org/Company",
},
{
"uri": "http://example.org/manages",
"name": "manages",
"comment": "manages a team",
"domain": ["http://example.org/Manager"],
"range": ["http://example.org/Employee"],
},
],
"data_properties": [
{
"uri": "http://example.org/hasAge",
"name": "hasAge",
"domain": "http://example.org/Person",
"range": "integer",
},
{
"uri": "http://example.org/hasName",
"name": "hasName",
"comment": "full name",
"domain": "http://example.org/Person",
"range": "string",
},
],
}
# ---------------------------------------------------------------------------
# Bug 1 — valid Turtle syntax
# ---------------------------------------------------------------------------
class TestTurtleSyntaxValidity:
"""Every subject block must have exactly one closing period at the end."""
def _blocks(self, turtle: str) -> list[str]:
"""Split output into non-empty logical blocks (separated by blank lines)."""
return [b.strip() for b in turtle.split("\n\n") if b.strip()]
def test_no_triple_after_period(self, exporter, full_ontology):
"""No predicate line may appear after a line that ends with ' .'."""
turtle = exporter._export_owl_turtle(full_ontology)
lines = turtle.splitlines()
for i, line in enumerate(lines):
stripped = line.rstrip()
if stripped.endswith(" .") and i + 1 < len(lines):
next_line = lines[i + 1].strip()
# next non-blank line must not be a predicate continuation
if next_line:
assert not next_line.startswith("rdfs:"), (
f"Predicate continuation after closing '.' at line {i + 1}: "
f"{lines[i]!r}{lines[i + 1]!r}"
)
def test_each_subject_block_ends_with_period(self, exporter, full_ontology):
"""Every subject block (class / property declaration) ends with exactly one '.'."""
turtle = exporter._export_owl_turtle(full_ontology)
blocks = self._blocks(turtle)
# skip the @prefix lines block and ontology declaration
subject_blocks = [b for b in blocks if b.startswith("<http://")]
for block in subject_blocks:
assert block.endswith("."), f"Block does not end with '.': {block!r}"
# Must not have a bare '.' on an interior line
interior_lines = block.splitlines()[:-1]
for ln in interior_lines:
assert not ln.rstrip().endswith(" ."), (
f"Premature closing period inside block: {ln!r}"
)
def test_class_with_subclassof_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/Employee",
"name": "Employee",
"subClassOf": "http://example.org/Person",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
# Must contain both predicates in the same block
assert 'rdfs:label "Employee"' in turtle
assert "rdfs:subClassOf <http://example.org/Person>" in turtle
# The subClassOf line must NOT come after a closing period
lines = turtle.splitlines()
for i, ln in enumerate(lines):
if "rdfs:subClassOf" in ln:
# Search backwards for the closest period-terminated line
for prev in reversed(lines[:i]):
prev_s = prev.rstrip()
if prev_s:
assert not prev_s.endswith(" ."), (
"rdfs:subClassOf appeared after a closed block"
)
break
def test_object_property_with_domain_range_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/worksFor",
"name": "worksFor",
"domain": "http://example.org/Employee",
"range": "http://example.org/Company",
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain <http://example.org/Employee>" in turtle
assert "rdfs:range <http://example.org/Company>" in turtle
lines = turtle.splitlines()
for i, ln in enumerate(lines):
if "rdfs:domain" in ln or "rdfs:range" in ln:
for prev in reversed(lines[:i]):
prev_s = prev.rstrip()
if prev_s:
assert not prev_s.endswith(" ."), (
"domain/range appeared after a closed block"
)
break
def test_class_with_comment_subclassof_both_present(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/X",
"name": "X",
"comment": "some comment",
"subClassOf": "http://example.org/Y",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:comment "some comment"' in turtle
assert "rdfs:subClassOf <http://example.org/Y>" in turtle
# block must end with single period
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
assert block.endswith(".")
assert block.count("\n.") == 0 # no bare period-only lines
# ---------------------------------------------------------------------------
# Bug 2 — data properties present in Turtle output
# ---------------------------------------------------------------------------
class TestDataPropertiesInTurtle:
def test_data_property_declared_as_datatypeproperty(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "owl:DatatypeProperty" in turtle
def test_data_property_uri_present(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "<http://example.org/hasAge>" in turtle
assert "<http://example.org/hasName>" in turtle
def test_data_property_label(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:label "hasAge"' in turtle
assert 'rdfs:label "hasName"' in turtle
def test_data_property_domain(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "rdfs:domain <http://example.org/Person>" in turtle
def test_data_property_range_uses_xsd_prefix(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "rdfs:range xsd:integer" in turtle
assert "rdfs:range xsd:string" in turtle
def test_data_property_comment(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:comment "full name"' in turtle
def test_data_properties_not_in_turtle_was_bug(self, exporter):
"""Regression: data_properties were silently dropped before the fix."""
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [],
"object_properties": [],
"data_properties": [
{
"uri": "http://example.org/birthDate",
"name": "birthDate",
"range": "date",
}
],
}
turtle = exporter._export_owl_turtle(ontology)
assert "owl:DatatypeProperty" in turtle, (
"Data properties must appear in Turtle output (was silently dropped)"
)
assert "<http://example.org/birthDate>" in turtle
assert "rdfs:range xsd:date" in turtle
def test_data_property_block_ends_with_period(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
blocks = [b.strip() for b in turtle.split("\n\n") if "owl:DatatypeProperty" in b]
assert blocks, "Expected at least one DatatypeProperty block"
for block in blocks:
assert block.endswith("."), f"DatatypeProperty block missing closing '.': {block!r}"
# ---------------------------------------------------------------------------
# Namespace and ontology header
# ---------------------------------------------------------------------------
class TestTurtleHeader:
def test_prefix_declarations(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "@prefix rdf:" in turtle
assert "@prefix rdfs:" in turtle
assert "@prefix owl:" in turtle
assert "@prefix xsd:" in turtle
def test_ontology_declaration(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "a owl:Ontology" in turtle
assert 'rdfs:label "TestOntology"' in turtle
assert 'owl:versionInfo "1.0"' in turtle
def test_ontology_description_included(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:comment "A test ontology"' in turtle
def test_ontology_without_description(self, exporter):
ontology = {"uri": "http://example.org/onto", "name": "NoDesc",
"classes": [], "object_properties": [], "data_properties": []}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:comment" not in turtle
# ---------------------------------------------------------------------------
# Object properties — list domain/range
# ---------------------------------------------------------------------------
class TestObjectPropertyListDomainRange:
def test_list_domain(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/p",
"name": "p",
"domain": ["http://example.org/A", "http://example.org/B"],
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain <http://example.org/A>" in turtle
assert "rdfs:domain <http://example.org/B>" in turtle
def test_list_range(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/p",
"name": "p",
"range": ["http://example.org/X", "http://example.org/Y"],
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range <http://example.org/X>" in turtle
assert "rdfs:range <http://example.org/Y>" in turtle
# ---------------------------------------------------------------------------
# equivalentClass support (also tested under Bug 1 guard)
# ---------------------------------------------------------------------------
class TestEquivalentClass:
def test_equivalent_class_in_turtle(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [
{
"uri": "http://example.org/Manager",
"name": "Manager",
"equivalentClass": "http://example.org/Supervisor",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "owl:equivalentClass <http://example.org/Supervisor>" in turtle
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
assert block.endswith(".")
# ---------------------------------------------------------------------------
# String escaping in Turtle literals (issue #478 review — escape_001)
# ---------------------------------------------------------------------------
class TestTurtleStringEscaping:
"""User-provided strings must be escaped before embedding in Turtle literals."""
def _onto(self, **kwargs):
base = {"uri": "http://example.org/onto", "name": "T",
"classes": [], "object_properties": [], "data_properties": []}
base.update(kwargs)
return base
def test_escape_ttl_str_double_quote(self, exporter):
assert exporter._escape_ttl_str('say "hello"') == r'say \"hello\"'
def test_escape_ttl_str_backslash(self, exporter):
assert exporter._escape_ttl_str("C:\\path") == "C:\\\\path"
def test_escape_ttl_str_newline(self, exporter):
assert exporter._escape_ttl_str("line1\nline2") == "line1\\nline2"
def test_escape_ttl_str_carriage_return(self, exporter):
assert exporter._escape_ttl_str("a\rb") == "a\\rb"
def test_escape_ttl_str_tab(self, exporter):
assert exporter._escape_ttl_str("col1\tcol2") == "col1\\tcol2"
def test_escape_ttl_str_combined(self, exporter):
raw = 'back\\slash and "quote"\nnewline'
escaped = exporter._escape_ttl_str(raw)
assert '\\"' in escaped
assert "\\\\" in escaped
assert "\\n" in escaped
def test_ontology_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(name='John"s Ontology')
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:label "John\\"s Ontology"' in turtle
assert 'rdfs:label "John"s Ontology"' not in turtle
def test_ontology_description_with_quote_is_escaped(self, exporter):
ontology = self._onto(description='Describes "things"')
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:comment "Describes \\"things\\""' in turtle
def test_class_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": 'My "Special" Class',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "My \"Special\" Class"' in turtle
def test_class_comment_with_backslash_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": "C",
"comment": "path is C:\\Users",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "path is C:\\Users"' in turtle
def test_class_comment_with_newline_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": "C",
"comment": "line1\nline2",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "line1\nline2"' in turtle
def test_object_property_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p",
"name": 'has"Value',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "has\"Value"' in turtle
def test_object_property_comment_with_quote_is_escaped(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p",
"name": "p",
"comment": 'links "A" to "B"',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "links \"A\" to \"B\""' in turtle
def test_data_property_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp",
"name": 'the "name" prop',
"range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "the \"name\" prop"' in turtle
def test_data_property_comment_with_quote_is_escaped(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp",
"name": "dp",
"comment": 'see "spec" §3',
"range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "see \"spec\" §3"' in turtle
def test_plain_strings_unchanged(self, exporter):
"""Strings without special chars must pass through unchanged."""
ontology = self._onto(
name="MyOntology",
classes=[{"uri": "http://example.org/C", "name": "SafeName"}],
)
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:label "MyOntology"' in turtle
assert 'rdfs:label "SafeName"' in turtle
# ---------------------------------------------------------------------------
# Null / missing optional fields — no KeyError raised (review null_check_001-3)
# ---------------------------------------------------------------------------
class TestNullFieldHandling:
"""Optional fields absent from dicts must not raise KeyError."""
def _onto(self, **kwargs):
base = {"uri": "http://example.org/onto", "name": "T",
"classes": [], "object_properties": [], "data_properties": []}
base.update(kwargs)
return base
def test_class_no_optional_fields(self, exporter):
ontology = self._onto(classes=[{"uri": "http://example.org/C", "name": "C"}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:Class" in turtle
def test_object_property_no_domain_no_range(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p"
}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:ObjectProperty" in turtle
assert "rdfs:domain" not in turtle
assert "rdfs:range" not in turtle
def test_data_property_no_domain_no_range(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp"
}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:DatatypeProperty" in turtle
assert "rdfs:domain" not in turtle
assert "rdfs:range" not in turtle
def test_data_property_none_domain(self, exporter):
"""Explicit None value for domain must not raise KeyError."""
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp",
"domain": None, "range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain" not in turtle
def test_data_property_none_range(self, exporter):
"""Explicit None value for range must not raise KeyError."""
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp",
"domain": "http://example.org/C", "range": None,
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range" not in turtle
def test_object_property_none_domain(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p",
"domain": None, "range": "http://example.org/X",
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain" not in turtle
def test_object_property_none_range(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p",
"domain": "http://example.org/A", "range": None,
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range" not in turtle
+84
View File
@@ -821,3 +821,87 @@ class TestPathFinderEdgeCases:
paths = self.finder.all_shortest_paths(single_node_graph, "A")
assert len(paths) == 0 # No paths to other nodes
class TestBidirectionalPathFinding:
"""Tests for the directed=False undirected-traversal mode (issue #469)."""
def setup_method(self):
self.finder = PathFinder()
# Single directed edge A → B. Reverse query B → A has no directed path.
self.digraph = nx.DiGraph()
self.digraph.add_edge("A", "B")
# --- directed=True (default) preserves existing behaviour ---
def test_bfs_directed_true_reverse_returns_empty(self):
"""B→A should find nothing when directed=True (default)."""
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=True)
assert path == []
def test_dijkstra_directed_true_reverse_returns_empty(self):
"""B→A should find nothing when directed=True (default)."""
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=True)
assert path == []
def test_bfs_directed_true_default_arg(self):
"""Omitting directed= should behave the same as directed=True."""
path = self.finder.bfs_shortest_path(self.digraph, "B", "A")
assert path == []
def test_dijkstra_directed_true_default_arg(self):
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A")
assert path == []
# --- directed=False finds path against edge orientation ---
def test_bfs_directed_false_reverse_single_edge(self):
"""directed=False must find B→A even though only A→B exists."""
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=False)
assert path == ["B", "A"]
def test_dijkstra_directed_false_reverse_single_edge(self):
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=False)
assert path == ["B", "A"]
def test_bfs_directed_false_forward_still_works(self):
"""directed=False should not break the forward direction."""
path = self.finder.bfs_shortest_path(self.digraph, "A", "B", directed=False)
assert path == ["A", "B"]
def test_dijkstra_directed_false_forward_still_works(self):
path = self.finder.dijkstra_shortest_path(self.digraph, "A", "B", directed=False)
assert path == ["A", "B"]
# --- multi-hop path where one edge is against the query direction ---
def test_bfs_directed_false_multihop(self):
"""A→B, C→B graph: directed=False lets us find A→B→C (i.e. A→C via B)."""
g = nx.DiGraph()
g.add_edge("A", "B")
g.add_edge("C", "B") # oriented towards B, not away from it
# undirected view: A-B-C, so A→C path exists
path = self.finder.bfs_shortest_path(g, "A", "C", directed=False)
assert path[0] == "A" and path[-1] == "C"
assert "B" in path
def test_dijkstra_directed_false_multihop(self):
g = nx.DiGraph()
g.add_edge("A", "B")
g.add_edge("C", "B")
path = self.finder.dijkstra_shortest_path(g, "A", "C", directed=False)
assert path[0] == "A" and path[-1] == "C"
assert "B" in path
# --- PathResponse.directed field ---
def test_path_response_directed_field_exists(self):
"""PathResponse must carry a directed field."""
from semantica.explorer.schemas import PathResponse
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"], directed=False)
assert r.directed is False
def test_path_response_directed_field_defaults_true(self):
from semantica.explorer.schemas import PathResponse
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"])
assert r.directed is True
@@ -0,0 +1,348 @@
"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
import sys
import os
import unittest
from unittest.mock import patch, MagicMock, call
from pydantic import BaseModel
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
class TestDeepSeekProviderInit(unittest.TestCase):
"""Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
def setUp(self):
from semantica.semantic_extract.providers import DeepSeekProvider
self.DeepSeekProvider = DeepSeekProvider
def test_base_url_set_on_init(self):
"""self.base_url must be set before _init_client is called (PR #482 regression)."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="fake-key")
self.assertTrue(
hasattr(provider, "base_url"),
"DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
)
self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
def test_base_url_points_to_v1_endpoint(self):
"""base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="fake-key")
self.assertIn("/v1", provider.base_url, "base_url must include /v1")
def test_init_client_uses_openai_not_deepseek(self):
"""_init_client must import openai.OpenAI, not deepseek.Client."""
mock_openai_cls = MagicMock()
mock_openai_instance = MagicMock()
mock_openai_cls.return_value = mock_openai_instance
with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
# Re-import to pick up patched sys.modules
import importlib
import semantica.semantic_extract.providers as providers_mod
importlib.reload(providers_mod)
DeepSeekProvider = providers_mod.DeepSeekProvider
provider = DeepSeekProvider(api_key="sk-test")
mock_openai_cls.assert_called_once_with(
api_key="sk-test",
base_url="https://api.deepseek.com/v1",
)
self.assertIs(provider.client, mock_openai_instance)
def test_init_client_no_api_key_leaves_client_none(self):
"""Without an API key, client must remain None."""
with patch("semantica.semantic_extract.providers.config") as mock_cfg:
mock_cfg.get_api_key.return_value = None
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key=None)
provider.client = None # simulate _init_client no-op
self.assertFalse(provider.is_available())
def test_init_client_handles_openai_import_error(self):
"""If openai is not installed, _init_client must set client=None, not raise."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None # manually simulate ImportError path
# Directly call _init_client with openai blocked
with patch.dict("sys.modules", {"openai": None}):
try:
provider._init_client()
except Exception as e:
self.fail(f"_init_client raised unexpectedly: {e}")
self.assertIsNone(provider.client)
def test_is_available_true_when_client_set(self):
"""is_available() returns True when self.client is an OpenAI instance."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = MagicMock()
self.assertTrue(provider.is_available())
def test_is_available_false_when_client_none(self):
"""is_available() returns False when self.client is None."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None
self.assertFalse(provider.is_available())
def test_no_deepseek_module_imported(self):
"""deepseek module must NOT be imported by _init_client after PR #482."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None
blocked = MagicMock()
blocked.__spec__ = None
with patch.dict("sys.modules", {"deepseek": None}):
# _init_client should succeed even if deepseek is completely absent
mock_openai = MagicMock()
mock_openai.OpenAI.return_value = MagicMock()
with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
try:
provider._init_client()
except Exception as e:
self.fail(f"_init_client raised when deepseek absent: {e}")
class TestDeepSeekProviderGenerate(unittest.TestCase):
"""Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
def _make_provider(self, api_key="sk-test"):
from semantica.semantic_extract.providers import DeepSeekProvider
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key=api_key)
provider.client = MagicMock()
return provider
def test_generate_uses_chat_completions(self):
"""generate() must call client.chat.completions.create."""
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = "hello"
provider.client.chat.completions.create.return_value = mock_resp
result = provider.generate("test prompt")
provider.client.chat.completions.create.assert_called_once()
self.assertEqual(result, "hello")
def test_generate_passes_model(self):
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = "x"
provider.client.chat.completions.create.return_value = mock_resp
provider.generate("p", model="deepseek-reasoner")
kwargs = provider.client.chat.completions.create.call_args[1]
self.assertEqual(kwargs["model"], "deepseek-reasoner")
def test_generate_structured_returns_parsed_json(self):
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = '{"key": "value"}'
provider.client.chat.completions.create.return_value = mock_resp
result = provider.generate_structured("test prompt")
self.assertEqual(result, {"key": "value"})
def test_generate_raises_without_client(self):
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = None
with self.assertRaises(ProcessingError):
provider.generate("prompt")
def test_generate_structured_raises_without_client(self):
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = None
with self.assertRaises(ProcessingError):
provider.generate_structured("prompt")
class TestDeepSeekInstructorPath(unittest.TestCase):
"""Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
def _make_provider(self, api_key="sk-test"):
from semantica.semantic_extract.providers import DeepSeekProvider
from unittest.mock import MagicMock
from openai import OpenAI
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key=api_key)
# After PR #482, client is an OpenAI instance
mock_client = MagicMock(spec=OpenAI)
provider.client = mock_client
return provider
def test_generate_typed_instructor_openai_isinstance_check(self):
"""After PR #482, client is OpenAI, so instructor path must use from_openai."""
from openai import OpenAI
from semantica.semantic_extract.providers import DeepSeekProvider
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = MagicMock(spec=OpenAI)
self.assertIsInstance(
provider.client, OpenAI,
"client must be OpenAI instance for instructor isinstance check to pass",
)
class TestVerboseModeAssignment(unittest.TestCase):
"""Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
def _make_openai_provider(self):
from semantica.semantic_extract.providers import OpenAIProvider
with patch.object(OpenAIProvider, "_init_client", return_value=None):
provider = OpenAIProvider(api_key="sk-test")
provider.client = MagicMock()
return provider
def test_generate_typed_no_verbose_no_name_error(self):
"""generate_typed must not raise NameError for verbose_mode when verbose not passed."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_instructor = MagicMock()
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = Schema(value="ok")
mock_instructor.from_openai.return_value = mock_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
try:
result = provider.generate_typed("prompt", Schema)
except NameError as e:
self.fail(f"NameError for verbose_mode: {e}")
except Exception:
pass # other errors are OK — we only care NameError is gone
def test_generate_typed_verbose_true_prints(self):
"""When verbose=True, generate_typed must print the confirmation line."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema, verbose=True)
except Exception:
pass
output = captured.getvalue()
# verbose_mode=True should trigger the print statement
self.assertIn("generate_typed", output)
def test_generate_typed_verbose_false_no_print(self):
"""When verbose=False (default), generate_typed must not print anything."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema)
except Exception:
pass
self.assertEqual(captured.getvalue(), "")
def test_generate_typed_verbose_from_config(self):
"""verbose_mode must also respect config-level verbose setting."""
provider = self._make_openai_provider()
provider.config["verbose"] = True
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema)
except Exception:
pass
self.assertIn("generate_typed", captured.getvalue())
class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
"""Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
"""generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
from semantica.semantic_extract.providers import DeepSeekProvider
from openai import OpenAI
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
mock_openai_client = MagicMock(spec=OpenAI)
provider.client = mock_openai_client
class Schema(BaseModel):
label: str
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("no from_provider")
mock_instructor.Mode.JSON = "json"
mock_instructor.Mode.TOOLS = "tools"
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
result = provider.generate_typed("classify this", Schema)
# Must have called from_openai with the existing client (not a fresh one)
mock_instructor.from_openai.assert_called_once_with(
mock_openai_client, mode="json"
)
self.assertEqual(result.label, "ok")
if __name__ == "__main__":
unittest.main()