Compare commits

...
79 Commits
Author SHA1 Message Date
Mohd Kaif ce3a8c9895 Add Enterprise Support section to README
Added enterprise support section with details on solutions and services.
2026-05-09 17:39:08 +05:30
Mohd Kaif 508e05d367 Update README.md 2026-05-08 17:30:25 +05:30
Mohd Kaif 03f99f016d Update README.md 2026-05-08 17:29:48 +05:30
Mohd Kaif 56e7d9d821 Fix #541: Convert mcp_server to package structure for pipx installation (#544)
- Convert mcp_server.py to package structure (semantica/mcp_server/)
- Add __init__.py and __main__.py for python -m support
- Add semantica-mcp console script entry point in pyproject.toml
- Fix API method calls (extract -> extract_entities/relations/triplets)
- Remove non-existent _result_cache imports
- Update documentation with both usage methods

Resolves pipx installation issue where semantica.mcp_server was not available.
Provides two ways to run: 'semantica-mcp' command or 'python -m semantica.mcp_server'.
2026-05-08 17:17:39 +05:30
Mohd Kaif 6860bdbec3 Merge pull request #540 from Hawksight-AI/conflicts
feat(deduplication): DuplicateDetector result limiting and ranking
2026-05-05 21:18:00 +05:30
Zohaib Hassnain ac5015bc3f fix(deduplication): normalize merged group keys 2026-05-05 20:25:51 +05:00
KaifAhmad1 29c72f59b3 docs(changelog): record Qodo review follow-up fixes for #533 and #534 2026-05-05 19:28:06 +05:30
KaifAhmad1 21c2f190f8 fix: resolve Qodo review bugs and quality issues (DuplicateDetector + ConflictDetector)
- bug_001: top_k_per_entity now uses OR semantics — keep a candidate if
  EITHER entity is under quota, preventing high-quality candidates being
  silently dropped when a popular counterpart saturates its quota
- bug_002: validate max_results and top_k_per_entity at construction;
  negative or non-int values raise ValueError instead of silent empty output
- bug_003: validate min_similarity in [0.0, 1.0] at construction;
  out-of-range values raise ValueError
- bug_004: harden ConflictDetector method='relationship' normalization —
  always produces List[Dict] before calling detect_relationship_conflicts
- quality_001: update detect_duplicates + incremental_detect docstrings to
  reflect configurable sort_by field (not hardcoded 'confidence')
- quality_002: add _normalize_entity_id helper (always str) used in both
  _apply_result_limits and _build_duplicate_groups for consistent ID handling

Backward compatible: callers not using new params see no behavior change.
58 tests pass (0 failures)
2026-05-05 19:25:48 +05:30
KaifAhmad1 8ef67b8bda feat(deduplication): add max_results, top_k_per_entity, min_similarity, sort_by to DuplicateDetector
Fixes #534

- New __init__ params: max_results, top_k_per_entity, min_similarity, sort_by
- _apply_result_limits: drop below min_similarity, sort by sort_by field,
  enforce top_k_per_entity per entity, cap at max_results globally
- Wired into detect_duplicates() and incremental_detect()
- 30 new tests in TestResultLimiting; full suite 42/42 passed
2026-05-05 19:16:58 +05:30
Mohd Kaif bc57837b86 Merge pull request #539 from Hawksight-AI/conflicts
fix(conflicts): consolidate duplicate detect_conflicts into single di…
2026-05-05 18:17:19 +05:30
KaifAhmad1 0439cf884d docs(changelog): record ConflictDetector.detect_conflicts duplicate definition fix (#533) 2026-05-05 18:13:06 +05:30
KaifAhmad1 141bf80394 fix(conflicts): consolidate duplicate detect_conflicts into single dispatcher method
Fixes #533

- Removes duplicate `detect_conflicts` definition that was silently overridden,
  causing AttributeError for callers passing `method=` or `property_name=` kwargs
- Merges dispatcher logic into the surviving method with `method="all"` default
  supporting: "all", "value", "property", "type", "relationship", "temporal",
  "logical", "entity"
- Fixes `method="relationship"` incorrectly defaulting `relationships` to the
  entities list; now defaults to `[]` with dict normalization
- Removes unreachable dead code block after try/except raise in
  `detect_entity_conflicts`
2026-05-05 18:00:48 +05:30
Mohd Kaif 0c4e18e256 fix(deps): remove gpu from [all] extra to fix Windows installation failure (#538)
* fix(deps): remove gpu extra from [all] to fix Windows installation failure

faiss-gpu has no Windows builds, so semantica[all] failed with
'No matching distribution found for faiss-gpu>=1.7.0' on Windows.
Removed gpu from both [all] lines — semantica[gpu] remains available
as an explicit opt-in for Linux GPU environments.

Closes #532

* docs(changelog): record faiss-gpu Windows installation failure fix (#532)
2026-05-05 16:57:33 +05:30
Mohd Kaif 39045d783b Merge pull request #537 from Hawksight-AI/utlis
fix(utils): route all progress tracker stdout writes through _safe_wr…
2026-05-05 16:34:43 +05:30
KaifAhmad1 afa54e1bbf docs(changelog): record progress tracker cp1252 UnicodeEncodeError fix (#531) 2026-05-05 16:19:14 +05:30
KaifAhmad1 a01b3c36fc fix(utils): route all progress tracker stdout writes through _safe_write to prevent UnicodeEncodeError on cp1252 consoles
Closes #531

- Replace 5 direct sys.stdout.write() calls in ConsoleProgressDisplay.update()
  with self._safe_write() so emoji/block characters are encoded safely on
  Windows cp1252 consoles
- Add TestProgressTrackerEncoding regression tests (3 cases) covering
  _safe_write, pipeline header, and auto emoji-disable on cp1252
2026-05-05 16:03:19 +05:30
Mohd Kaif 0dda380580 Merge pull request #536 from Hawksight-AI/fix/semantic-extract-import-cycle
fix(semantic_extract): break extractor import cycle
2026-05-05 14:34:57 +05:30
KaifAhmad1andZohaib Hassan e7c9f6e7f3 fix(tests): restore sys.modules after mock injection in test_retry_logic
test_retry_logic.py injected sys.modules["openai"] = MagicMock() at module
level so providers.py could be imported without the real openai package.
Those mocks were never restored, leaving openai (and spacy, instructor etc.)
as MagicMock objects for the entire test session. This caused
test_pr482_deepseek_openai tests to receive a MagicMock when importing
openai.OpenAI, making MagicMock(spec=OpenAI) raise InvalidSpecError.

Fix: save original sys.modules entries before injection and restore them
immediately after the semantica imports that needed the mocks complete.
The mock objects remain bound inside the already-imported provider module,
so test_retry_logic tests are unaffected; other test modules now see the
real packages again.

Co-authored-by: Zohaib Hassan <zohaib179949@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-05-05 14:21:36 +05:30
KaifAhmad1andZohaib Hassan b828ebfef0 chore: resolve CHANGELOG.md merge conflict with main
main restructured [Unreleased] into ### Added / ### Fixed sections.
Moved PR #536 semantic_extract circular import fix entry into ### Fixed
below the PR #535 ingest lazy-load entry; kept ### Added content from
main intact.

Co-authored-by: Zohaib Hassan <zohaib179949@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-05-05 13:56:48 +05:30
KaifAhmad1andZohaib Hassan 6330627ddb docs(changelog): record semantic_extract circular import fix and Qodo review fix (#536)
Co-authored-by: Zohaib Hassan <zohaib179949@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-05-05 13:54:01 +05:30
KaifAhmad1andZohaib Hassan 24e327d161 fix(tests): add from __future__ import annotations for Py3.8 compatibility
subprocess.CompletedProcess[str] as a return annotation is not subscriptable
at runtime on Python 3.8, causing test collection to abort before any tests
run. Adding PEP 563 deferred evaluation makes all annotations strings at
import time, restoring 3.8 compatibility without changing behaviour on 3.9+.

Co-authored-by: Zohaib Hassan <zohaib179949@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
2026-05-05 13:40:11 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> b9f3c59443 Potential fix for pull request finding 'Duplicate key in dict literal'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-05 13:28:09 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 34955ba246 Potential fix for pull request finding 'Explicit export is not defined'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-05 13:27:54 +05:30
Mohd KaifandCopilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> 95b46f12b6 Potential fix for pull request finding 'Explicit export is not defined'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-05 13:25:07 +05:30
245ff76b99 fix(ingest): lazy-load optional ingestion backends (#535)
* fix(ingest): lazy-load optional ingestion backends

* fix(ingest): address qodo review — use ModuleNotFoundError and guard ConfigurationError

Bug 1: Replace overbroad `except ImportError` with `except ModuleNotFoundError` in
__getattr__ (__init__.py) and all four optional-backend loaders (methods.py). This
prevents internal import errors inside a backend module from being silently rewritten
into a misleading "package not installed" message. Also simplifies _is_missing_dependency
to rely solely on exc.name now that ModuleNotFoundError always sets it.

Bug 2: Add `except ConfigurationError: raise` before the blanket `except Exception`
handlers in ingest_web, ingest_feed, ingest_repository, and ingest_email. Missing
optional dependencies are expected user-config issues and must not be logged as errors.

Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>

* docs(changelog): record lazy ingest backends fix and qodo review fixes (#535)

Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>

* fix(tests): set exc.name on blocker's ModuleNotFoundError to match Python import machinery

OptionalDependencyBlocker was constructing ModuleNotFoundError with only a
message string, leaving .name as None. _is_missing_dependency checks exc.name
directly (the string-scan fallback was removed when switching to
ModuleNotFoundError), so the ConfigurationError conversion never triggered and
the test asserted the wrong exception type.

Python's import machinery always sets .name to the top-level module name when
it raises ModuleNotFoundError; the blocker now does the same.

Co-authored-by: Zohaib Hassan (@ZohaibHassan16) <zohaib179949@gmail.com>
Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>

---------

Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
Co-authored-by: Zohaib Hassan (@ZohaibHassan16) <zohaib179949@gmail.com>
2026-05-05 13:09:14 +05:30
a2b6a481dc chore: resolve CHANGELOG.md merge conflict with main
main restructured [Unreleased] into ### Added / ### Fixed sections.
Moved PR #535 lazy-load fix and Ontology Hub post-review fix entries
into ### Fixed; kept ### Added content from main intact.

Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-05-05 12:56:06 +05:30
c7edba88ea fix(tests): set exc.name on blocker's ModuleNotFoundError to match Python import machinery
OptionalDependencyBlocker was constructing ModuleNotFoundError with only a
message string, leaving .name as None. _is_missing_dependency checks exc.name
directly (the string-scan fallback was removed when switching to
ModuleNotFoundError), so the ConfigurationError conversion never triggered and
the test asserted the wrong exception type.

Python's import machinery always sets .name to the top-level module name when
it raises ModuleNotFoundError; the blocker now does the same.

Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-05-05 12:45:43 +05:30
eb8598e0cf docs(changelog): record lazy ingest backends fix and qodo review fixes (#535)
Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-05-05 12:37:17 +05:30
64d4157644 fix(ingest): address qodo review — use ModuleNotFoundError and guard ConfigurationError
Bug 1: Replace overbroad `except ImportError` with `except ModuleNotFoundError` in
__getattr__ (__init__.py) and all four optional-backend loaders (methods.py). This
prevents internal import errors inside a backend module from being silently rewritten
into a misleading "package not installed" message. Also simplifies _is_missing_dependency
to rely solely on exc.name now that ModuleNotFoundError always sets it.

Bug 2: Add `except ConfigurationError: raise` before the blanket `except Exception`
handlers in ingest_web, ingest_feed, ingest_repository, and ingest_email. Missing
optional dependencies are expected user-config issues and must not be logged as errors.

Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
2026-05-05 12:30:48 +05:30
Zohaib Hassnain e1b1e63541 fix(semantic_extract): break extractor import cycle 2026-05-05 02:26:57 +05:00
Zohaib Hassnain 6b0a8e60ce fix(ingest): lazy-load optional ingestion backends 2026-05-05 02:01:27 +05:00
Mohd Kaif 8b22a58b8f Update print statement from 'Hello' to 'Goodbye' 2026-05-04 23:36:03 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ba286a55ea security(deps): update mkdocs requirement from >=1.5.0 to >=1.6.1 (#507)
Updates the requirements on [mkdocs](https://github.com/mkdocs/mkdocs) to permit the latest version.
- [Release notes](https://github.com/mkdocs/mkdocs/releases)
- [Commits](https://github.com/mkdocs/mkdocs/compare/1.5.0...1.6.1)

---
updated-dependencies:
- dependency-name: mkdocs
  dependency-version: 1.6.1
  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-04 22:34:36 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> f610eff0ed security(deps): update mkdocs-mermaid2-plugin requirement (#508)
Updates the requirements on [mkdocs-mermaid2-plugin](https://github.com/fralau/mkdocs-mermaid2-plugin) to permit the latest version.
- [Release notes](https://github.com/fralau/mkdocs-mermaid2-plugin/releases)
- [Changelog](https://github.com/fralau/mkdocs-mermaid2-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fralau/mkdocs-mermaid2-plugin/compare/v1.0.1...v1.2.3)

---
updated-dependencies:
- dependency-name: mkdocs-mermaid2-plugin
  dependency-version: 1.2.3
  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-04 21:30:16 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e9e7278720 security(deps): update mkdocs-jupyter requirement (#509)
Updates the requirements on [mkdocs-jupyter](https://github.com/danielfrg/mkdocs-jupyter) to permit the latest version.
- [Changelog](https://github.com/danielfrg/mkdocs-jupyter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/danielfrg/mkdocs-jupyter/commits)

---
updated-dependencies:
- dependency-name: mkdocs-jupyter
  dependency-version: 0.26.3
  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-03 12:32:57 +05:30
Mohd Kaif 6b2cafd02e Merge pull request #524 from Hawksight-AI/feat/onto-hub-subissue-520
feat(ontology): add alignments, health dashboard, and SHACL studio
2026-05-02 17:21:54 +05:30
90d857a98f docs(changelog): add PR #524 entry — Alignments, Health Dashboard & SHACL Studio
Covers all features, backend endpoints, schemas, helpers, fix-up commits,
and 14 integration tests added during the subissue-3 implementation cycle.

Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
Co-authored-by: ZohaibHassan16 <zohaib@example.com>
2026-05-02 16:53:09 +05:30
1861ca578c chore: resolve merge conflicts with origin/main
- Keep SequenceMatcher + Tuple imports; delegate Literal to typing_extensions
- Preserve both _ALIGNMENT_RELATIONS (subissue-520) and _INGEST_FORMAT_SUFFIXES (main)
- Keep our OntologyAlignment-typed _get_alignment_store; add main's _get_drafts,
  _get_proposals, _get_versions, _alignment_key, _coerce_alignment, _version_field
- Import OntologyEditor + VersionsTab (main) alongside ShaclStudio (subissue-520)
- All 14 subissue-3 tests pass post-resolution

Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
Co-authored-by: ZohaibHassan16 <zohaib@example.com>
2026-05-02 16:45:10 +05:30
63acc7a66e fix(ontology): address Qodo automated review findings from PR #524
Backend (semantica/explorer/routes/ontology.py):
- suggest-alignments: add TF-IDF character-ngram embeddings via sklearn
  (SimilarityCalculator-compatible cosine scoring) so embedding_similarity
  is populated in results; combined score = 0.4*label + 0.6*embedding when
  available, falling back to label-only when sklearn is absent
- suggest-alignments: add token-overlap prefilter before SequenceMatcher so
  zero-Jaccard pairs are skipped without computing full similarity; add
  _MAX_ENTITIES_PER_SIDE=500 per-ontology cap on top of the existing
  _MAX_ANALYSIS_NODES global cap
- suggest-alignments: remove dead try/except OntologyEngine.create_alignment
  block that always failed silently (no TripletStore configured); replace
  with a comment explaining the intentional ephemeral-only storage model
- health: replace O(alignments x entities) any() scans for alignment coverage
  with O(1) set membership checks via assessed_ids
- shacl/validate: run rdflib.Graph().parse(format='turtle') syntax check on
  the submitted Turtle before returning; invalid syntax now raises 422 instead
  of returning a misleading unavailable/success response

Frontend:
- AlignmentsTab: add pairwise alignment matrix section that groups recorded
  alignments by (source_ontology, target_ontology) pair; each cell shows
  color-coded relation badges per RELATION_COLORS; clicking a badge populates
  the create/edit form for quick editing; matrix is shown when at least two
  ontologies are loaded
- ShaclStudio: add selectedShapeId state and fullShacl ref; each shape row in
  the library is now a clickable button that extracts its Turtle block from
  the full SHACL and pre-populates the Monaco editor; a "View all" toggle
  restores the full SHACL; selected shape ID is shown in the editor header
- GraphWorkspace: fix viewMode race in external focus effect — call
  setSelectedNodeId directly instead of going through focusNode(), which
  captured a stale viewMode in its closure; remove focusNode from the
  dependency array since it is no longer called

Tests (14 passing, was 11):
- Add test_suggest_alignments_returns_embedding_similarity: asserts
  embedding_similarity is non-null when sklearn is available
- Add test_shacl_validate_rejects_invalid_turtle_syntax: asserts 422 on
  syntactically invalid Turtle
- Add test_health_alignment_coverage_uses_set_lookup: asserts alignment
  dimension score is non-zero after recording an alignment, verifying the
  O(1) set lookup path works correctly end-to-end

Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
2026-05-02 16:38:09 +05:30
00ceb09960 fix(ontology): address review blockers from PR #524
Backend:
- Replace false conforms=True SHACL stub with status=unavailable always;
  live validation cannot be wired until OntologyEngine.validate_graph is
  connected to a data graph — a stub that returns conforms=True misleads
  users editing shapes
- Cap node/edge fetches in health, suggest-alignments, and SHACL generation
  at _MAX_ANALYSIS_NODES (5 000) with a logger.warning when the graph
  exceeds the limit; unbounded limit=999_999 fetches cause OOM on large graphs
- Set SHACL health dimension score to 0.0 (was 70.0) when status=unavailable;
  exclude unavailable dimensions from the total_score average so they neither
  inflate nor deflate the result
- Allow alignments to reference external/unloaded URIs (e.g. schema.org)
  without raising 404; label falls back to URI fragment or caller-supplied
  source_label/target_label fields added to OntologyAlignmentRequest
- Fix _alignment_id to use uuid.NAMESPACE_OID instead of NAMESPACE_URL;
  the composite key is not a URL
- Fix _summarize_shapes to normalise \r\n before splitting on .\n so shape
  parsing works correctly on Windows line endings

Frontend:
- Wrap handleSave/handleSuggest/handleRemove/handleAcceptSuggestion in
  useCallback in AlignmentsTab for consistency with sibling components
- Add ephemeral-storage banner in AlignmentsTab warning that alignments are
  session-memory-only and not persisted across restarts
- Fix exportReport in HealthTab to append/remove anchor from document before
  clicking and defer URL.revokeObjectURL to avoid Blob URL leak in some browsers
- Derive health dimension grid column count from health.dimensions.length
  instead of the hardcoded repeat(5, ...) that breaks if the backend adds
  or removes a dimension
- Add minimal Monarch tokenizer for the Monaco turtle language registration
  in ShaclStudio so prefix declarations, IRIs, SHACL properties, comments,
  and string literals are syntax-highlighted; previously the editor rendered
  as plain text despite theme rules being defined

Tests (11 passing, was 5):
- Rename test_shacl_validate_has_stable_contract to
  test_shacl_validate_returns_unavailable and assert status == unavailable
- Add test_shacl_validate_rejects_empty_turtle (expects 422)
- Add test_health_returns_404_for_unknown_ontology
- Add test_health_shacl_dimension_is_zero_when_unavailable with total_score check
- Add test_delete_unknown_alignment_returns_404
- Add test_alignment_upsert_is_idempotent (verifies ID stability and created_at
  preservation across updates)
- Add test_alignment_accepts_external_uri (verifies no 404 for schema.org URIs)
- Relax test_alignment_suggestions_are_ranked label assertions to substring
  checks so the test survives similarity algorithm changes

Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
Co-authored-by: ZohaibHassan16 <zohaib@hawksight.ai>
2026-05-02 15:53:47 +05:30
Mohd Kaif 2909e0fa14 Merge pull request #523 from Hawksight-AI/feature/ontology-hub-endpoints
feat: implement Ontology Hub endpoints with Semantica module integration
2026-05-02 11:20:41 +05:30
KaifAhmad1 d2574990fa Merge branch 'feature/ontology-hub-endpoints' of https://github.com/Hawksight-AI/semantica into feature/ontology-hub-endpoints 2026-05-02 11:12:22 +05:30
KaifAhmad1 53d1da87c0 fix: prevent invalid domain/range edges in ontology creation
- Fix domain_uri/range_uri always being truthy strings
- Only create rdfs:domain/rdfs:range edges when domain/range are non-empty strings
- Add proper validation with .strip() to handle whitespace-only values
- Apply fix to both 'data' and 'text' mode ontology creation
- Prevents pollution of graph with invalid edges to namespace root

Fixes issue where empty domain/range values like '' or None would still create
edges pointing to namespace root (e.g., 'https://ex/#/') instead of being
properly omitted.
2026-05-02 11:12:03 +05:30
Zohaib Hassnain 9e916a82b5 fix(ontology): address hub endpoint review blockers 2026-05-02 00:02:06 +05:00
Zohaib Hassnain e8bf0e50d3 feat(ontology): add alignments health and shacl studio 2026-05-01 22:59:15 +05:00
KaifAhmad1 269fdaa9fb feat: implement Ontology Hub endpoints with Semantica module integration
- Add comprehensive ontology API endpoints (27 total)
- Integrate OntologyEngine for validation, SHACL, SKOS, alignments
- Integrate VersionManager for versioning and diffing
- Integrate ChangeLogEntry for audit trails
- Integrate OntologyIngestor for RDF parsing
- Add frontend components: OntologyEditor, ProposalReview, VersionsTab
- Update CHANGELOG with detailed feature documentation
- Add proper error handling and fallback mechanisms
- Fix import issues and dependencies
- All endpoints tested and verified working

Features implemented:
- Draft management with audit trails
- Change proposals with structured diffing
- Version comparison and publishing
- Ontology loading with multiple format support
- SKOS vocabulary management
- Cross-ontology alignments
- Visual ontology editor
- Registry and search functionality
2026-05-01 22:26:21 +05:30
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 2d9bbf08b1 deps(deps): update pytest-benchmark requirement from >=4.0.0 to >=5.2.3 (#522)
Updates the requirements on [pytest-benchmark](https://github.com/ionelmc/pytest-benchmark) to permit the latest version.
- [Changelog](https://github.com/ionelmc/pytest-benchmark/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/ionelmc/pytest-benchmark/compare/v4.0.0...v5.2.3)

---
updated-dependencies:
- dependency-name: pytest-benchmark
  dependency-version: 5.2.3
  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 18:05:17 +05:30
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
75 changed files with 18321 additions and 3077 deletions
+427 -2138
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -6,6 +6,8 @@
**A Framework for Building Context Graphs and Decision Intelligence Layers for AI**
🌐 **[Website](https://getsemantica.ai/)** | 📚 **[Documentation](https://docs.getsemantica.ai/)**
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI](https://img.shields.io/pypi/v/semantica.svg)](https://pypi.org/project/semantica/)
@@ -1029,6 +1031,27 @@ pytest tests/
---
## 🏢 Enterprise Support
**Production-grade Semantica for your organization**
🚀 **[Website](https://getsemantica.ai/)** — Enterprise solutions and custom domain-specific implementations
**Domain-Specific Solutions:**
- 🏥 **Healthcare** — Clinical decision support, patient safety, compliance
- **Finance** — Fraud detection, regulatory compliance, risk management
- ⚖️ **Legal** — Evidence analysis, contract review, case law reasoning
- **Cybersecurity** — Threat attribution, incident response, provenance
- 🏛️ **Government** — Policy tracking, classified information governance
**Custom Solutions Available:**
- Private cloud deployment
- 🛡️ Enterprise security compliance
- Custom analytics and reporting
- 🎯 Professional implementation services
---
## 🤝 Contributing
All contributions welcome — bug fixes, features, tests, and docs.
+1 -1
View File
@@ -1,7 +1,7 @@
# Benchmark Tools
pytest>=7.0.0
pytest-benchmark>=4.0.0
pytest-benchmark>=5.2.3
# Core Utils
+45
View File
@@ -19,6 +19,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",
@@ -3467,6 +3468,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+1
View File
@@ -23,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",
+980 -7
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;
+2
View File
@@ -3,6 +3,7 @@ import type {
GraphArrowVisibilityPolicy,
GraphBadgeKind,
GraphEdgeVariant,
GraphEntityShapeVariant,
GraphLabelVisibilityPolicy,
GraphNodeShapeVariant,
} from "../workspaces/GraphWorkspace/graphTheme";
@@ -36,6 +37,7 @@ export interface NodeAttributes {
borderSize?: number;
nodeVariant?: GraphNodeShapeVariant;
nodeShapeVariant?: GraphNodeShapeVariant;
entityShape?: GraphEntityShapeVariant;
badgeKind?: GraphBadgeKind;
badgeCount?: number;
ringColor?: string;
@@ -25,8 +25,12 @@ import {
collectInteractionRefreshTargets,
createInteractionState,
isEdgeInteractable,
classifyFullGraphEdge,
mapFullEdgeClassToVisualState,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveDistanceEdgeStyle,
resolveDistanceNodeStyle,
resolveNodeElementStyle,
resolveNodeVisualState,
} from "./graphSceneState";
@@ -47,15 +51,30 @@ import {
drawSemanticaNodeHover,
drawSemanticaNodeLabel,
} from "./sigmaNativeRendering";
import {
buildGraphStructureCurveCache,
clearGraphStructureLayer,
createGraphStructureCacheKey,
drawGraphStructureLayer,
evaluateGraphStructureLayerGate,
getGraphStructureLayerDiagnostics,
type GraphStructureCurveCache,
} from "./graphStructureLayer";
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDistanceVisualState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphFullEdgeClass,
GraphFullEdgeClassCounts,
GraphFullEdgeClassDiagnostics,
GraphInteractionState,
GraphLayoutStatus,
GraphRuntimeDiagnosticsSnapshot,
GraphStructureLayerDiagnostics,
GraphTemporalState,
GraphViewMode,
} from "./types";
@@ -85,6 +104,7 @@ export interface GraphCanvasProps {
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
distanceVisualState?: GraphDistanceVisualState;
effectsState: GraphEffectsState;
temporalState?: GraphTemporalState | null;
isLayoutRunning: boolean;
@@ -98,7 +118,7 @@ export interface GraphCanvasProps {
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
onCameraStateChange?: (cameraState: GraphCameraState) => void;
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
}
@@ -800,6 +820,7 @@ function drawNodeBadge(
}
type ReducerSceneState = {
viewMode: GraphViewMode;
zoomTier: GraphZoomTier;
hoveredNodeId: string | null;
selectedNodeId: string;
@@ -810,15 +831,135 @@ type ReducerSceneState = {
edgeEndpointIds: Set<string>;
pathNodeIds: Set<string>;
pathEdgeIds: Set<string>;
highlightedIncidentEdgeIds: Set<string>;
overviewBackboneEdgeIds: Set<string>;
distanceVisualState?: GraphDistanceVisualState;
};
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
"hidden",
"backbone",
"bridge",
"local-context",
"selected",
"path",
"muted",
];
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
counts[edgeClass] = 0;
return counts;
}, {} as GraphFullEdgeClassCounts);
}
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
}
function scoreIncidentEdge(
attrs: EdgeAttributes,
edgeId: string,
otherEndpointId: string,
visibleNeighborIds: Set<string>,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
selectedEdgeId: string,
): number {
if (pathEdgeIds.has(edgeId)) {
return Number.POSITIVE_INFINITY;
}
if (selectedEdgeId && edgeId === selectedEdgeId) {
return Number.POSITIVE_INFINITY;
}
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
+ visualPriority * 2
+ normalizedWeight * 1.4
+ relationshipStrength;
}
function buildHighlightedIncidentEdgeIds(
displayGraph: GraphSceneGraph,
interactionState: GraphInteractionState,
displayState: GraphDisplayStateSnapshot | undefined,
focusIds: Set<string>,
pathEdgeIds: Set<string>,
): Set<string> {
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
return new Set();
}
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
return new Set();
}
const visibleNeighborIds = new Set(
(displayState?.selectedVisibleNeighborIds ?? [])
.filter((nodeId) => displayGraph.hasNode(nodeId)),
);
const candidates: Array<{ edgeId: string; score: number }> = [];
displayGraph.edges(primaryNodeId).forEach((edge) => {
const edgeId = String(edge);
if (!displayGraph.hasEdge(edgeId)) {
return;
}
const [source, target] = displayGraph.extremities(edgeId);
const sourceId = String(source);
const targetId = String(target);
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
candidates.push({
edgeId,
score: scoreIncidentEdge(
attrs,
edgeId,
otherEndpointId,
visibleNeighborIds,
focusIds,
pathEdgeIds,
interactionState.selectedEdgeId,
),
});
});
candidates.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.edgeId.localeCompare(right.edgeId);
});
const selected = new Set<string>();
candidates.forEach((candidate) => {
if (
candidate.edgeId === interactionState.selectedEdgeId
|| pathEdgeIds.has(candidate.edgeId)
|| selected.size < cap
) {
selected.add(candidate.edgeId);
}
});
return selected;
}
function buildReducerSceneState(
displayGraph: GraphSceneGraph,
interactionState: GraphInteractionState,
analyticsSnapshot: GraphAnalyticsSnapshot | null,
displayState?: GraphDisplayStateSnapshot,
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
distanceVisualState?: GraphDistanceVisualState,
): ReducerSceneState {
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
const primaryNodeId = hoveredNodeId || selectedNodeId;
const focusIds = primaryNodeId
? (
@@ -827,8 +968,10 @@ function buildReducerSceneState(
: new Set<string>()
)
: new Set<string>();
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
return {
viewMode,
zoomTier,
hoveredNodeId,
selectedNodeId,
@@ -838,8 +981,75 @@ function buildReducerSceneState(
focusIds,
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
pathNodeIds: new Set(activePath),
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
pathEdgeIds,
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
distanceVisualState,
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
displayGraph,
interactionState,
displayState,
focusIds,
pathEdgeIds,
),
};
}
function getFullGraphEdgeClass(
displayGraph: GraphSceneGraph,
edgeId: string,
currentState: ReducerSceneState,
): GraphFullEdgeClass {
if (!displayGraph.hasEdge(edgeId)) {
return "hidden";
}
const [source, target] = displayGraph.extremities(edgeId);
const sourceId = String(source);
const targetId = String(target);
const sourceAttrs = displayGraph.hasNode(sourceId)
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
: undefined;
const targetAttrs = displayGraph.hasNode(targetId)
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
: undefined;
return classifyFullGraphEdge(
edgeId,
sourceId,
targetId,
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.highlightedIncidentEdgeIds,
currentState.overviewBackboneEdgeIds,
sourceAttrs,
targetAttrs,
);
}
function buildFullGraphEdgeClassDiagnostics(
displayGraph: GraphSceneGraph,
currentState: ReducerSceneState,
): GraphFullEdgeClassDiagnostics {
const counts = createFullEdgeClassCounts();
displayGraph.forEachEdge((edgeId) => {
const edgeClass = currentState.viewMode === "full"
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
: "hidden";
counts[edgeClass] += 1;
});
return {
mode: currentState.viewMode,
zoomTier: currentState.zoomTier,
totalEdges: displayGraph.size,
visibleEdges: displayGraph.size - counts.hidden,
counts,
updatedAt: Date.now(),
};
}
@@ -889,21 +1099,34 @@ function applySceneState(
data.label,
cameraRatio,
);
const distanceStyle = currentState.viewMode === "full"
? resolveDistanceNodeStyle(
GRAPH_THEME,
currentState.zoomTier,
style,
currentState.distanceVisualState,
String(node),
)
: {};
const resolvedStyle = { ...style, ...distanceStyle };
return {
...data,
color: style.color,
shellColor: style.shellColor,
coreScale: style.coreScale,
size: style.size,
forceLabel: style.forceLabel,
label: style.label,
zIndex: style.zIndex,
hidden: style.hidden,
borderColor: style.borderColor,
borderSize: style.borderSize,
ringColor: style.showRing ? style.ringColor : style.borderColor,
ringSize: style.ringSize,
color: resolvedStyle.color,
shellColor: resolvedStyle.shellColor,
coreScale: resolvedStyle.coreScale,
size: resolvedStyle.size,
forceLabel: resolvedStyle.forceLabel,
label: resolvedStyle.label,
zIndex: resolvedStyle.zIndex,
hidden: resolvedStyle.hidden,
borderColor: resolvedStyle.borderColor,
borderSize: resolvedStyle.borderSize,
ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
ringSize: resolvedStyle.ringSize,
entityShape: resolvedStyle.entityShape,
entityShapeKind: resolvedStyle.entityShapeKind,
entityAspectRatio: resolvedStyle.entityAspectRatio,
};
});
@@ -927,18 +1150,35 @@ function applySceneState(
const attrs = data as EdgeAttributes;
const [source, target] = currentGraph.extremities(edge);
const stableEdgeId = String(edge);
const state = resolveEdgeVisualState(
stableEdgeId,
source,
target,
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.overviewBackboneEdgeIds,
const hasActiveInteraction = Boolean(
currentState.hoveredNodeId
|| currentState.selectedNodeId
|| currentState.selectedEdgeId
|| currentState.pathEdgeIds.size > 0,
);
const fullEdgeClass = currentState.viewMode === "full"
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
: undefined;
const state = currentState.viewMode === "full"
? mapFullEdgeClassToVisualState(
fullEdgeClass ?? "hidden",
{
hoveredNodeId: currentState.hoveredNodeId,
hasActiveInteraction,
},
)
: resolveEdgeVisualState(
stableEdgeId,
String(source),
String(target),
currentState.zoomTier,
currentState.hoveredNodeId,
currentState.selectedNodeId,
currentState.selectedEdgeId,
currentState.focusIds,
currentState.pathEdgeIds,
currentState.highlightedIncidentEdgeIds,
);
const style = resolveEdgeElementStyle(
GRAPH_THEME,
currentState.zoomTier,
@@ -946,16 +1186,29 @@ function applySceneState(
attrs,
source,
target,
currentState.viewMode,
stableEdgeId,
fullEdgeClass,
);
const distanceStyle = currentState.viewMode === "full"
? resolveDistanceEdgeStyle(
style,
currentState.distanceVisualState,
String(source),
String(target),
fullEdgeClass,
)
: {};
const resolvedStyle = { ...style, ...distanceStyle };
return {
...data,
hidden: style.hidden,
type: style.type,
color: style.color,
size: style.size,
zIndex: style.zIndex,
curvature: style.curvature,
hidden: resolvedStyle.hidden,
type: resolvedStyle.type,
color: resolvedStyle.color,
size: resolvedStyle.size,
zIndex: resolvedStyle.zIndex,
curvature: resolvedStyle.curvature,
};
});
@@ -999,6 +1252,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
selectedEdgeId,
activePath = [],
activePathEdgeIds = [],
distanceVisualState,
effectsState,
temporalState,
isLayoutRunning,
@@ -1017,6 +1271,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
) {
const containerRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLCanvasElement>(null);
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
const structureLayerLastDrawAtRef = useRef<number | null>(null);
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
const sigmaRef = useRef<Sigma | null>(null);
const fa2Ref = useRef<FA2Layout | null>(null);
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
@@ -1029,6 +1287,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const graphVersionRef = useRef(graphVersion);
const selectedNodeIdRef = useRef(selectedNodeId);
const focusedNodeIdRef = useRef(focusedNodeId);
const distanceVisualStateRef = useRef(distanceVisualState);
const viewModeRef = useRef(viewMode);
const onNodeClickRef = useRef(onNodeClick);
const onEdgeClickRef = useRef(onEdgeClick);
@@ -1037,6 +1296,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
const appliedGraphVersionRef = useRef<number | null>(null);
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
const layoutSyncFrameRef = useRef<number | null>(null);
@@ -1054,6 +1314,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
graphVersionRef.current = graphVersion;
selectedNodeIdRef.current = selectedNodeId;
focusedNodeIdRef.current = focusedNodeId;
distanceVisualStateRef.current = distanceVisualState;
viewModeRef.current = viewMode;
onNodeClickRef.current = onNodeClick;
onEdgeClickRef.current = onEdgeClick;
@@ -1100,6 +1361,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
interactionStateRef.current = interactionState;
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
useEffect(() => {
if (!isLayoutRunning) {
setLayoutSettledEpoch((epoch) => epoch + 1);
}
}, [displayGraph, graphVersion, isLayoutRunning]);
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
const analyticsBase = useMemo(
@@ -1110,11 +1378,55 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
);
const reducerSceneState = useMemo(
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
[analyticsSnapshot, displayGraph, interactionState],
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
[analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
);
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
reducerSceneStateRef.current = reducerSceneState;
const edgeClassDiagnostics = useMemo(
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
[displayGraph, reducerSceneState],
);
const structureLayerGate = useMemo(
() => evaluateGraphStructureLayerGate({
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
viewMode,
isLayoutRunning,
edgeDiagnostics: edgeClassDiagnostics,
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
}),
[edgeClassDiagnostics, isLayoutRunning, viewMode],
);
const structureLayerCache = useMemo(() => {
if (!structureLayerGate.enabled) {
return null;
}
const cacheKey = createGraphStructureCacheKey({
graphVersion,
zoomTier,
layoutSettledEpoch,
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
});
return buildGraphStructureCurveCache({
graphRef: displayGraph,
cacheKey,
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
});
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
structureLayerCacheRef.current = structureLayerCache;
const structureLayerDiagnostics = useMemo(
() => getGraphStructureLayerDiagnostics({
gate: structureLayerGate,
cache: structureLayerCache,
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
canvasAvailable: Boolean(structureLayerCanvasRef.current),
lastDrawAt: structureLayerLastDrawAtRef.current,
}),
[structureLayerCache, structureLayerGate],
);
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
graphVersion,
viewMode,
@@ -1237,17 +1549,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
});
fitDisplayGraphInView();
return;
}
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
const selectedDisplayNodeId = selectionNodeIds[0];
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
if (selectedDisplayData) {
const viewportPoint = sigma.graphToViewport({
x: selectedDisplayData.x,
y: selectedDisplayData.y,
});
const dimensions = sigma.getDimensions();
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
debugGraphRuntime("camera-selection-visible-noop", {
nodeId,
selectedDisplayNodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
viewportX: viewportPoint.x,
viewportY: viewportPoint.y,
});
sigma.scheduleRefresh();
return;
}
}
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
if (!bounds) {
if (attempt < 3) {
debugGraphRuntime("camera-selection-deferred", {
debugGraphRuntime("camera-selection-display-bounds-deferred", {
nodeId,
graphVersion: graphVersionRef.current,
attempt: attempt + 1,
viewMode: viewModeRef.current,
contextCount: selectionNodeIds.length,
});
if (deferredFocusFrameRef.current !== null) {
window.cancelAnimationFrame(deferredFocusFrameRef.current);
@@ -1257,45 +1592,43 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
centerSelectionInViewInternal(nodeId, attempt + 1);
});
} else {
debugGraphRuntime("camera-selection-fallback-fit", {
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
nodeId,
graphVersion: graphVersionRef.current,
viewMode: viewModeRef.current,
contextCount: selectionNodeIds.length,
});
fitDisplayGraphInView();
}
return;
}
const globalBounds = computeDisplayedGraphBounds(sigma, currentDisplayGraph);
const selectionBBox = expandGraphSpaceBounds(bounds, globalBounds, {
paddingRatio: 0.2,
minSpanRatio: 0.035,
minSpanFloor: 0.02,
});
if (!selectionBBox) {
debugGraphRuntime("camera-selection-invalid-bounds", {
nodeId,
graphVersion: graphVersionRef.current,
count: bounds.count,
});
fitDisplayGraphInView();
return;
}
const camera = sigma.getCamera();
const currentCameraState = camera.getState();
const target = {
x: (bounds.minX + bounds.maxX) / 2,
y: (bounds.minY + bounds.maxY) / 2,
ratio: currentCameraState.ratio,
angle: currentCameraState.angle,
};
animateCameraToBounds("fit-selection-context", selectionBBox, {
debugGraphRuntime("camera-selection-gentle-center", {
nodeId,
contextCount: bounds.count,
boundsSource: "displayed-selection-context",
minX: bounds.minX,
maxX: bounds.maxX,
minY: bounds.minY,
maxY: bounds.maxY,
referenceMinX: globalBounds?.minX ?? null,
referenceMaxX: globalBounds?.maxX ?? null,
referenceMinY: globalBounds?.minY ?? null,
referenceMaxY: globalBounds?.maxY ?? null,
targetX: target.x,
targetY: target.y,
preservedRatio: target.ratio,
});
}, [animateCameraToBounds, fitDisplayGraphInView]);
void camera.animate(
target,
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
);
}, []);
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
const sigma = sigmaRef.current;
@@ -1580,6 +1913,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
}
});
try {
const structureCanvas = sigma.createCanvas("structure", {
beforeLayer: "nodes",
afterLayer: "edges",
style: {
pointerEvents: "none",
},
});
structureLayerCanvasRef.current = structureCanvas;
} catch (error) {
debugGraphRuntime("structure-layer-create-failed", {
error: error instanceof Error ? error.message : String(error),
});
structureLayerCanvasRef.current = null;
}
requestAnimationFrame(() => {
syncCameraState(sigma);
});
@@ -1607,6 +1956,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
debugGraphRuntime("sigma-killed", {
graphVersion: graphVersionRef.current,
});
if (structureLayerCanvasRef.current) {
try {
sigma.killLayer("structure");
} catch {
// Sigma.kill() also cleans layers; ignore if already removed.
}
}
structureLayerCanvasRef.current = null;
sigma.kill();
}
if (deferredFocusFrameRef.current !== null) {
@@ -1649,6 +2006,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
appliedGraphVersionRef.current = graphVersion;
fittedDisplaySignatureRef.current = null;
previousInteractionStateRef.current = null;
previousDistanceVisualStateRef.current = undefined;
behaviorContextRef.current = getBehaviorContext(sigma);
if (runtimeRef.current) {
runtimeRef.current.displayGraph = displayGraph;
@@ -1764,11 +2122,30 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
container?.clientWidth ?? 0,
container?.clientHeight ?? 0,
);
onDiagnosticsChange(availability);
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
onDiagnosticsChange({
effectAvailability: availability,
edgeClasses: edgeClassDiagnostics,
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
distanceVisual: distanceVisualStateRef.current,
});
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
console.debug("[Edge Truth]", edgeClassDiagnostics);
}
}, [
analyticsSnapshot,
edgeClassDiagnostics,
effectsState,
interactionState,
isLayoutRunning,
onDiagnosticsChange,
structureLayerDiagnostics,
temporalState,
distanceVisualState,
]);
useEffect(() => {
previousInteractionStateRef.current = null;
previousDistanceVisualStateRef.current = undefined;
}, [displayGraph]);
useEffect(() => {
@@ -1778,13 +2155,68 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
}
const previousInteractionState = previousInteractionStateRef.current;
const refreshTargets = previousInteractionState
const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
const refreshTargets = !distanceVisualStateChanged && previousInteractionState
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
: undefined;
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
previousInteractionStateRef.current = interactionState;
}, [displayGraph, interactionState, reducerSceneStateRef]);
previousDistanceVisualStateRef.current = distanceVisualState;
}, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
const drawStructureLayerFrame = useCallback(() => {
const sigma = sigmaRef.current;
const canvas = structureLayerCanvasRef.current;
const cache = structureLayerCacheRef.current;
const diagnostics = getGraphStructureLayerDiagnostics({
gate: structureLayerGate,
cache,
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
canvasAvailable: Boolean(canvas),
lastDrawAt: structureLayerLastDrawAtRef.current,
});
structureLayerDiagnosticsRef.current = diagnostics;
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
clearGraphStructureLayer(canvas);
return;
}
const drawn = drawGraphStructureLayer({
sigma,
canvas,
cache,
});
if (drawn) {
structureLayerLastDrawAtRef.current = Date.now();
structureLayerDiagnosticsRef.current = {
...diagnostics,
lastDrawAt: structureLayerLastDrawAtRef.current,
};
}
}, [structureLayerGate]);
useEffect(() => {
const sigma = sigmaRef.current;
if (!graphReady || !sigma) {
return;
}
const draw = () => drawStructureLayerFrame();
sigma.on("afterRender", draw);
draw();
return () => {
sigma.off("afterRender", draw);
};
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
useEffect(() => {
if (isLayoutRunning || viewMode !== "full") {
clearGraphStructureLayer(structureLayerCanvasRef.current);
}
}, [isLayoutRunning, viewMode]);
const drawOverlayFrame = useCallback(() => {
const sigma = sigmaRef.current;
@@ -1847,6 +2279,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
...pathNodeIds,
...(primaryNodeId ? [primaryNodeId] : []),
]);
const lensFocusIds = new Set<string>();
if (primaryNodeId) {
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
if (!displayGraph.hasEdge(edgeId)) {
return;
}
const [source, target] = displayGraph.extremities(edgeId);
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
if (displayGraph.hasNode(otherEndpointId)) {
lensFocusIds.add(otherEndpointId);
}
});
}
const now = performance.now() / 1000;
if (!isLayoutRunning) {
@@ -1909,7 +2354,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
});
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
drawLensLayer(context, sigma, primaryNodeId, focusIds);
drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
}
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
@@ -1,7 +1,7 @@
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 = {
@@ -17,6 +17,13 @@ export type PathResponse = {
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 {
@@ -46,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 {
@@ -83,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) {
@@ -110,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>
@@ -172,10 +307,10 @@ 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>
@@ -191,19 +326,19 @@ export function GraphInspectorPanel({
if (!effectiveNodeId) {
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
<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: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{nodeId}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
<div 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: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
<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.")}
@@ -233,23 +368,23 @@ 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 }}>
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
<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" }}>
<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: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
<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.")}
@@ -267,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>
@@ -316,12 +451,16 @@ export function GraphInspectorPanel({
<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.
@@ -343,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={{
@@ -352,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>
@@ -382,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>
@@ -403,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>
@@ -423,27 +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(88,166,255,0.08)",
border: "1px solid rgba(88,166,255,0.2)",
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",
@@ -452,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 = {
@@ -500,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",
@@ -527,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,
@@ -542,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,
@@ -559,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,
@@ -567,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",
};
File diff suppressed because it is too large Load Diff
@@ -510,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}`);
@@ -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);
@@ -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);
}
},
};
}
@@ -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";
@@ -53,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;
@@ -134,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;
@@ -171,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;
@@ -302,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",
@@ -317,44 +425,98 @@ 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.72,
labelThreshold: 0.995,
labelBudget: 4,
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: 0.94,
labelThreshold: 0.93,
labelBudget: 18,
labelThreshold: 0.95,
labelBudget: 12,
edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.75,
edgeSizeScale: 0.92,
showBadges: false,
showCurves: false,
showCurves: true,
showContextualArrows: false,
},
inspection: {
@@ -428,11 +590,11 @@ export const GRAPH_THEME: GraphTheme = {
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
},
states: {
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
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 },
},
@@ -443,6 +605,68 @@ export const GRAPH_THEME: GraphTheme = {
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,
@@ -467,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 },
@@ -483,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,
@@ -629,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(")) {
@@ -5,13 +5,14 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
import type {
GraphAnalyticsSnapshot,
GraphCameraState,
GraphDistanceVisualState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectsState,
GraphInteractionState,
GraphLayoutSource,
GraphLayoutStatus,
GraphRuntimeDiagnosticsSnapshot,
GraphTemporalState,
GraphViewMode,
} from "./types";
@@ -35,7 +36,7 @@ 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;
}
@@ -51,6 +52,7 @@ export interface GraphSceneProps extends GraphSceneEventMap {
selectedEdgeId: string;
activePath?: string[];
activePathEdgeIds?: string[];
distanceVisualState?: GraphDistanceVisualState;
effectsState: GraphEffectsState;
temporalState?: GraphTemporalState | null;
isLayoutRunning: boolean;
@@ -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 = {
@@ -12,7 +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;
@@ -92,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;
@@ -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,406 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import { GitMerge, Loader2, Sparkles, Trash2 } from "lucide-react";
import {
loadAlignments,
loadOntologyRegistry,
removeAlignment,
saveAlignment,
suggestAlignments,
} from "./api";
import type { AlignmentRelation, AlignmentSuggestion, OntologyAlignment, OntologyEntry } from "./types";
const RELATIONS: AlignmentRelation[] = [
"owl:equivalentClass",
"owl:equivalentProperty",
"skos:exactMatch",
"skos:closeMatch",
"skos:broadMatch",
"skos:narrowMatch",
"skos:relatedMatch",
];
const RELATION_COLORS: Record<AlignmentRelation, string> = {
"owl:equivalentClass": "#7ce7d3",
"owl:equivalentProperty": "#7ce7d3",
"skos:exactMatch": "#9ee8d7",
"skos:closeMatch": "#58a6ff",
"skos:broadMatch": "#f2b66d",
"skos:narrowMatch": "#f2b66d",
"skos:relatedMatch": "#d2a8ff",
};
export function AlignmentsTab() {
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
const [alignments, setAlignments] = useState<OntologyAlignment[]>([]);
const [suggestions, setSuggestions] = useState<AlignmentSuggestion[]>([]);
const [sourceOntology, setSourceOntology] = useState("");
const [targetOntology, setTargetOntology] = useState("");
const [sourceUri, setSourceUri] = useState("");
const [targetUri, setTargetUri] = useState("");
const [relation, setRelation] = useState<AlignmentRelation>("skos:exactMatch");
const [confidence, setConfidence] = useState(0.86);
const [provenance, setProvenance] = useState("");
const [source, setSource] = useState("Ontology Hub");
const [reviewer, setReviewer] = useState("");
const [threshold, setThreshold] = useState(0.68);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const reload = useCallback(async () => {
setError("");
try {
const [registryData, alignmentData] = await Promise.all([
loadOntologyRegistry(),
loadAlignments(),
]);
setRegistry(registryData);
setAlignments(alignmentData);
setSourceOntology((current) => current || registryData[0]?.uri || "");
setTargetOntology((current) => current || registryData[1]?.uri || registryData[0]?.uri || "");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load ontology alignments.");
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const relationCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const item of alignments) {
counts.set(item.relation, (counts.get(item.relation) ?? 0) + 1);
}
return counts;
}, [alignments]);
// Pairwise matrix: group alignments by (source_ontology, target_ontology) pair.
const matrix = useMemo(() => {
function ontologyOfUri(uri: string): string {
for (const entry of registry) {
if (uri === entry.uri || uri.startsWith(entry.uri + "#") || uri.startsWith(entry.uri + "/")) {
return entry.uri;
}
}
const hashIdx = uri.lastIndexOf("#");
if (hashIdx > 0) return uri.substring(0, hashIdx);
const slashIdx = uri.lastIndexOf("/");
return slashIdx > 0 ? uri.substring(0, slashIdx) : uri;
}
const cells: Map<string, OntologyAlignment[]> = new Map();
for (const alignment of alignments) {
const key = `${ontologyOfUri(alignment.source_uri)}|||${ontologyOfUri(alignment.target_uri)}`;
const bucket = cells.get(key) ?? [];
bucket.push(alignment);
cells.set(key, bucket);
}
return { ontologies: registry, cells };
}, [registry, alignments]);
const handleSave = useCallback(async () => {
if (!sourceUri.trim() || !targetUri.trim()) {
setError("Provide both source and target entity URIs.");
return;
}
setBusy(true);
setError("");
try {
await saveAlignment({
source_uri: sourceUri.trim(),
target_uri: targetUri.trim(),
relation,
confidence,
provenance: provenance || undefined,
source: source || undefined,
reviewer: reviewer || undefined,
});
setSourceUri("");
setTargetUri("");
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save alignment.");
} finally {
setBusy(false);
}
}, [sourceUri, targetUri, relation, confidence, provenance, source, reviewer, reload]);
const handleSuggest = useCallback(async () => {
setBusy(true);
setError("");
try {
const data = await suggestAlignments({
source_ontology_uri: sourceOntology || undefined,
target_ontology_uri: targetOntology || undefined,
threshold,
limit: 40,
});
setSuggestions(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not suggest alignments.");
} finally {
setBusy(false);
}
}, [sourceOntology, targetOntology, threshold]);
const handleAcceptSuggestion = useCallback((suggestion: AlignmentSuggestion) => {
setSourceUri(suggestion.source_uri);
setTargetUri(suggestion.target_uri);
setRelation(suggestion.relation);
setConfidence(Math.max(0.1, Math.min(1, suggestion.score)));
setProvenance(suggestion.reason);
}, []);
const handleRemove = useCallback(async (id: string) => {
setBusy(true);
setError("");
try {
await removeAlignment(id);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not remove alignment.");
} finally {
setBusy(false);
}
}, [reload]);
return (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><GitMerge size={14} /> Alignment Matrix</div>
<h2 style={titleStyle}>Cross-ontology mappings</h2>
<p style={textStyle}>
Manage equivalence and SKOS match relations with confidence, provenance,
reviewer context, and label-based suggestions.
</p>
</div>
<div style={summaryGridStyle}>
<Metric label="Mappings" value={alignments.length} />
<Metric label="Relations" value={relationCounts.size} />
<Metric label="Suggestions" value={suggestions.length} />
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
<div style={ephemeralBannerStyle}>
Alignments are stored in server memory and are not persisted across restarts.
Export your graph or ontology to preserve recorded mappings.
</div>
{matrix.ontologies.length >= 2 ? (
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Pairwise alignment matrix</h3>
<div style={{ overflowX: "auto" }}>
<table style={matrixTableStyle}>
<thead>
<tr>
<th style={matrixCornerStyle} />
{matrix.ontologies.map((col) => (
<th key={col.uri} style={matrixColHeaderStyle}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{matrix.ontologies.map((row) => (
<tr key={row.uri}>
<td style={matrixRowHeaderStyle}>{row.name}</td>
{matrix.ontologies.map((col) => {
const key = `${row.uri}|||${col.uri}`;
const cellItems = matrix.cells.get(key) ?? [];
const isDiag = row.uri === col.uri;
return (
<td key={col.uri} style={{ ...matrixCellStyle, background: isDiag ? "rgba(255,255,255,0.015)" : undefined }}>
{isDiag ? <span style={mutedStyle}></span> : cellItems.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{cellItems.map((item) => (
<span
key={item.id}
style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}44`, cursor: "pointer", fontSize: 9 }}
title={`${item.source_label}${item.target_label} (${Math.round(item.confidence * 100)}%)`}
onClick={() => {
setSourceUri(item.source_uri);
setTargetUri(item.target_uri);
setRelation(item.relation);
setConfidence(item.confidence);
setProvenance(item.provenance ?? "");
}}
>
{item.relation.split(":")[1]}
</span>
))}
</div>
) : <span style={{ color: "rgba(127,208,255,0.15)", fontSize: 12 }}>·</span>}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p style={{ ...mutedStyle, marginTop: 10 }}>Click a relation badge to load it into the editor below.</p>
</section>
) : null}
<div style={gridStyle}>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Create or update alignment</h3>
<label style={labelStyle}>Source entity URI</label>
<input style={inputStyle} value={sourceUri} onChange={(event) => setSourceUri(event.target.value)} />
<label style={labelStyle}>Target entity URI</label>
<input style={inputStyle} value={targetUri} onChange={(event) => setTargetUri(event.target.value)} />
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Relation</label>
<select style={inputStyle} value={relation} onChange={(event) => setRelation(event.target.value as AlignmentRelation)}>
{RELATIONS.map((item) => <option key={item}>{item}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Confidence {confidence.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={confidence}
onChange={(event) => setConfidence(Number(event.target.value))}
style={{ width: "100%" }}
/>
</div>
</div>
<label style={labelStyle}>Provenance note</label>
<textarea style={{ ...inputStyle, minHeight: 74, resize: "vertical" }} value={provenance} onChange={(event) => setProvenance(event.target.value)} />
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Source</label>
<input style={inputStyle} value={source} onChange={(event) => setSource(event.target.value)} />
</div>
<div>
<label style={labelStyle}>Reviewer</label>
<input style={inputStyle} value={reviewer} onChange={(event) => setReviewer(event.target.value)} />
</div>
</div>
<button style={primaryButtonStyle} disabled={busy} onClick={handleSave}>
{busy ? <Loader2 size={14} className="spin" /> : <GitMerge size={14} />}
Save alignment
</button>
</section>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Suggest alignments</h3>
<div style={twoColStyle}>
<div>
<label style={labelStyle}>Source ontology</label>
<select style={inputStyle} value={sourceOntology} onChange={(event) => setSourceOntology(event.target.value)}>
<option value="">Any ontology</option>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Target ontology</label>
<select style={inputStyle} value={targetOntology} onChange={(event) => setTargetOntology(event.target.value)}>
<option value="">Any ontology</option>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</div>
<label style={labelStyle}>Similarity threshold {threshold.toFixed(2)}</label>
<input
type="range"
min="0.25"
max="0.95"
step="0.01"
value={threshold}
onChange={(event) => setThreshold(Number(event.target.value))}
style={{ width: "100%" }}
/>
<button style={secondaryButtonStyle} disabled={busy} onClick={handleSuggest}>
<Sparkles size={14} />
Suggest alignments
</button>
<div style={suggestionListStyle}>
{suggestions.map((item) => (
<button key={`${item.source_uri}-${item.target_uri}-${item.relation}`} style={suggestionStyle} onClick={() => handleAcceptSuggestion(item)}>
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label}</span>
<span style={{ color: RELATION_COLORS[item.relation] }}>{item.relation}</span>
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label}</span>
<span style={{ color: "#8fa8c6" }}>{Math.round(item.score * 100)}%</span>
</button>
))}
{!suggestions.length ? <p style={mutedStyle}>Run suggestions to review ranked candidate mappings.</p> : null}
</div>
</section>
</div>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Recorded alignments</h3>
<div style={tableStyle}>
{alignments.map((item) => (
<div key={item.id} style={rowStyle}>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label || item.source_uri}</div>
<div style={monoStyle}>{item.source_uri}</div>
</div>
<div style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}55` }}>
{item.relation}
</div>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label || item.target_uri}</div>
<div style={monoStyle}>{item.target_uri}</div>
</div>
<div style={confidenceStyle}>{Math.round(item.confidence * 100)}%</div>
<button style={iconButtonStyle} disabled={busy} onClick={() => handleRemove(item.id)} title="Remove alignment">
<Trash2 size={14} />
</button>
</div>
))}
{!alignments.length ? <p style={mutedStyle}>No alignments recorded yet.</p> : null}
</div>
</section>
</div>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div style={metricStyle}>
<span style={{ color: "#9ee8d7", fontSize: 20, fontWeight: 900 }}>{value.toLocaleString()}</span>
<span style={{ color: "#6a7f97", fontSize: 11 }}>{label}</span>
</div>
);
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
const summaryGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(100px, 1fr))", gap: 10, minWidth: 320 };
const metricStyle: CSSProperties = { padding: 14, borderRadius: 16, background: "rgba(255,255,255,0.035)", border: "1px solid rgba(127,208,255,0.1)", display: "flex", flexDirection: "column", gap: 4 };
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "minmax(320px, 0.9fr) minmax(360px, 1.1fr)", gap: 16 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)" };
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "10px 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const twoColStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 };
const primaryButtonStyle: CSSProperties = { marginTop: 14, width: "100%", border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "11px 13px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8 };
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
const suggestionListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8, marginTop: 14, maxHeight: 260, overflow: "auto" };
const suggestionStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto", gap: 10, alignItems: "center", textAlign: "left", border: "1px solid rgba(127,208,255,0.1)", borderRadius: 12, background: "rgba(255,255,255,0.03)", padding: 10, cursor: "pointer" };
const tableStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const rowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto auto", gap: 12, alignItems: "center", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const monoStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const relationBadgeStyle: CSSProperties = { padding: "5px 9px", border: "1px solid", borderRadius: 999, fontSize: 10, fontWeight: 900 };
const confidenceStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
const iconButtonStyle: CSSProperties = { width: 34, height: 34, borderRadius: 10, border: "1px solid rgba(255,157,175,0.18)", background: "rgba(255,157,175,0.08)", color: "#ff9daf", cursor: "pointer" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
const ephemeralBannerStyle: CSSProperties = { padding: "9px 14px", borderRadius: 12, color: "#f2b66d", background: "rgba(242,182,109,0.08)", border: "1px solid rgba(242,182,109,0.22)", fontSize: 12 };
const matrixTableStyle: CSSProperties = { borderCollapse: "collapse", minWidth: "100%", fontSize: 12 };
const matrixCornerStyle: CSSProperties = { padding: "8px 12px", borderBottom: "1px solid rgba(127,208,255,0.1)" };
const matrixColHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderBottom: "1px solid rgba(127,208,255,0.1)", textAlign: "center", whiteSpace: "nowrap" };
const matrixRowHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderRight: "1px solid rgba(127,208,255,0.1)", whiteSpace: "nowrap" };
const matrixCellStyle: CSSProperties = { padding: "8px 10px", borderBottom: "1px solid rgba(127,208,255,0.06)", borderRight: "1px solid rgba(127,208,255,0.06)", textAlign: "center", verticalAlign: "middle", minWidth: 100 };
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { Download, HeartPulse, Loader2, Wrench } from "lucide-react";
import { loadOntologyHealth, loadOntologyRegistry } from "./api";
import type { OntologyEntry, OntologyHealthResponse, HealthIssue } from "./types";
interface HealthTabProps {
onFixInEditor?: (entityUri: string) => void;
}
export function HealthTab({ onFixInEditor }: HealthTabProps) {
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
const [selectedUri, setSelectedUri] = useState("");
const [health, setHealth] = useState<OntologyHealthResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
loadOntologyRegistry()
.then((entries) => {
if (cancelled) return;
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
return () => {
cancelled = true;
};
}, []);
const loadHealth = useCallback(async (uri: string) => {
if (!uri) return;
setLoading(true);
setError("");
try {
setHealth(await loadOntologyHealth(uri));
} catch (err) {
setHealth(null);
setError(err instanceof Error ? err.message : "Could not load ontology health.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadHealth(selectedUri);
}, [selectedUri, loadHealth]);
const exportReport = useCallback(() => {
if (!health) return;
const blob = new Blob([JSON.stringify(health, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${health.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-health.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => URL.revokeObjectURL(url), 100);
}, [health]);
return (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><HeartPulse size={14} /> Ontology Health</div>
<h2 style={titleStyle}>Quality and governance signals</h2>
<p style={textStyle}>
Score completeness, consistency, SHACL readiness, alignment coverage,
and documentation quality for the selected ontology.
</p>
</div>
<div style={selectorShellStyle}>
<label style={labelStyle}>Ontology</label>
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
{loading ? (
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
) : health ? (
<>
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
<div style={scoreCardStyle}>
<span style={scoreValueStyle}>{Math.round(health.total_score)}</span>
<span style={mutedStyle}>Total health score</span>
<button style={secondaryButtonStyle} onClick={exportReport}><Download size={14} /> Export report</button>
</div>
{health.dimensions.map((dimension) => (
<div key={dimension.key} style={dimensionCardStyle}>
<div style={dimensionHeadStyle}>
<span style={{ color: "#ebf3ff", fontWeight: 900 }}>{dimension.label}</span>
<span style={statusBadgeStyle(dimension.status)}>{dimension.status}</span>
</div>
<div style={barTrackStyle}>
<div style={{ ...barFillStyle, width: `${dimension.score}%`, background: dimensionColor(dimension.score, dimension.status) }} />
</div>
<div style={dimensionFootStyle}>
<span>{Math.round(dimension.score)} / 100</span>
<span>{dimension.detail}</span>
</div>
</div>
))}
</section>
<section style={cardStyle}>
<h3 style={sectionTitleStyle}>Actionable issues</h3>
<div style={issueListStyle}>
{health.issues.map((issue) => (
<IssueRow key={issue.id} issue={issue} onFixInEditor={onFixInEditor} />
))}
{!health.issues.length ? <p style={mutedStyle}>No actionable issues reported for this ontology.</p> : null}
</div>
</section>
</>
) : (
<div style={emptyStyle}>Select an ontology to compute health signals.</div>
)}
</div>
);
}
function IssueRow({ issue, onFixInEditor }: { issue: HealthIssue; onFixInEditor?: (entityUri: string) => void }) {
return (
<div style={issueRowStyle}>
<div style={severityDotStyle(issue.severity)} />
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{issue.entity_label || issue.category}</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.45 }}>{issue.message}</div>
{issue.entity_uri ? <div style={monoStyle}>{issue.entity_uri}</div> : null}
</div>
<span style={categoryStyle}>{issue.category}</span>
{issue.entity_uri ? (
<button style={smallButtonStyle} onClick={() => onFixInEditor?.(issue.entity_uri || "")}>
<Wrench size={13} />
Fix in Editor
</button>
) : (
<div />
)}
</div>
);
}
function dimensionColor(score: number, status: string) {
if (status === "unavailable") return "#6a7f97";
if (score >= 80) return "#7ce7d3";
if (score >= 55) return "#f2b66d";
return "#ff9daf";
}
function statusBadgeStyle(status: string): CSSProperties {
const color = status === "ok" ? "#7ce7d3" : status === "unavailable" ? "#6a7f97" : "#f2b66d";
return {
color,
background: `${color}18`,
border: `1px solid ${color}30`,
borderRadius: 999,
padding: "2px 7px",
fontSize: 10,
fontWeight: 900,
textTransform: "uppercase",
};
}
function severityDotStyle(severity: string): CSSProperties {
const color = severity === "critical" ? "#ff9daf" : severity === "warning" ? "#f2b66d" : "#58a6ff";
return { width: 10, height: 10, borderRadius: "50%", background: color, boxShadow: `0 0 18px ${color}55`, marginTop: 5 };
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
const selectorShellStyle: CSSProperties = { minWidth: 320 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const scoreGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "220px repeat(5, minmax(180px, 1fr))", gap: 12 };
const scoreCardStyle: CSSProperties = { padding: 18, borderRadius: 20, background: "rgba(15,35,52,0.88)", border: "1px solid rgba(124,231,211,0.2)", display: "flex", flexDirection: "column", gap: 10 };
const scoreValueStyle: CSSProperties = { color: "#9ee8d7", fontSize: 52, lineHeight: 1, fontWeight: 950, letterSpacing: "-0.06em" };
const dimensionCardStyle: CSSProperties = { padding: 16, borderRadius: 18, background: "rgba(9,19,34,0.78)", border: "1px solid rgba(127,208,255,0.12)" };
const dimensionHeadStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center", marginBottom: 12 };
const barTrackStyle: CSSProperties = { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" };
const barFillStyle: CSSProperties = { height: "100%", borderRadius: 999 };
const dimensionFootStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, color: "#8fa8c6", fontSize: 12, marginTop: 10 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
const issueListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
const issueRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "14px 1fr auto auto", gap: 12, alignItems: "start", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const categoryStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.22)", borderRadius: 999, padding: "4px 8px", fontSize: 10, fontWeight: 900 };
const smallButtonStyle: CSSProperties = { display: "inline-flex", gap: 6, alignItems: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
const secondaryButtonStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", justifyContent: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 12, padding: "10px 12px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 900 };
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
const loadingStyle: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 8, color: "#8fa8c6", padding: 18 };
const emptyStyle: CSSProperties = { color: "#6a7f97", padding: 22 };
const errorStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
@@ -0,0 +1,498 @@
import { useCallback, useEffect, useState } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
MarkerType,
} from "@xyflow/react";
import type { Connection, Edge, Node } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Plus,
GitBranch,
User,
Shield,
FileText,
Layout,
Send,
Pencil,
Trash2,
} from "lucide-react";
type OntologyNodeData = {
label?: string;
type?: string;
};
type OntologyNode = Node<OntologyNodeData>;
type OntologyEdge = Edge<Record<string, unknown>>;
const nodeTypes = {
classNode: ({ data }: { data: OntologyNodeData }) => (
<div style={classNodeStyle}>
<div style={classNodeHeader}>{data.label}</div>
<div style={classNodeSub}>{data.type}</div>
</div>
),
};
const classNodeStyle: React.CSSProperties = {
padding: "12px 16px",
borderRadius: "8px",
background: "linear-gradient(135deg, rgba(74, 163, 255, 0.15), rgba(74, 163, 255, 0.05))",
border: "1px solid rgba(127, 208, 255, 0.3)",
color: "#ebf3ff",
fontSize: "13px",
fontWeight: "600",
minWidth: "140px",
textAlign: "center",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
};
const classNodeHeader: React.CSSProperties = {
fontSize: "14px",
fontWeight: "700",
marginBottom: "4px",
};
const classNodeSub: React.CSSProperties = {
fontSize: "11px",
color: "#8fa8c6",
fontWeight: "500",
};
interface DraftDiff {
added_classes: string[];
removed_classes: string[];
modified_classes: Record<string, Record<string, any>>;
added_properties: string[];
removed_properties: string[];
modified_properties: Record<string, Record<string, any>>;
added_restrictions: Record<string, any>[];
removed_restrictions: Record<string, any>[];
added_axioms: Record<string, any>[];
removed_axioms: Record<string, any>[];
annotation_changes: Record<string, Record<string, any>>;
}
interface RegistryEntry {
uri: string;
name: string;
}
export function OntologyEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState<OntologyNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<OntologyEdge>([]);
const [selectedElement, setSelectedElement] = useState<OntologyNode | OntologyEdge | null>(null);
const [registry, setRegistry] = useState<RegistryEntry[]>([]);
const [ontologyUri, setOntologyUri] = useState<string>("");
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
added_classes: [],
removed_classes: [],
modified_classes: {},
added_properties: [],
removed_properties: [],
modified_properties: {},
added_restrictions: [],
removed_restrictions: [],
added_axioms: [],
removed_axioms: [],
annotation_changes: {},
});
const [isSaving, setIsSaving] = useState(false);
const [showContext, setShowContext] = useState<{ x: number; y: number; type: string; element: OntologyNode | OntologyEdge } | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/api/ontology/registry")
.then((response) => (response.ok ? response.json() : []))
.then((entries: RegistryEntry[]) => {
if (cancelled) return;
setRegistry(entries);
setOntologyUri((current) => current || entries[0]?.uri || "");
})
.catch((error) => {
console.error("Failed to load ontology registry:", error);
});
return () => {
cancelled = true;
};
}, []);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
[setEdges]
);
const addClass = useCallback(() => {
const newId = `class_${Date.now()}`;
const newNode: OntologyNode = {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewClass", type: "owl:Class" },
};
setNodes((nds) => [...nds, newNode]);
setDraftDiff((prev) => ({
...prev,
added_classes: [...prev.added_classes, newId],
}));
}, [setNodes]);
const addProperty = useCallback(() => {
if (nodes.length < 2) {
alert("Add at least two classes before creating a property edge.");
return;
}
const newId = `prop_${Date.now()}`;
const newEdge: OntologyEdge = {
id: newId,
source: nodes[0].id,
target: nodes[1].id,
label: "hasProperty",
type: "smoothstep",
animated: true,
};
setEdges((eds) => [...eds, newEdge]);
setDraftDiff((prev) => ({
...prev,
added_properties: [...prev.added_properties, newId],
}));
}, [nodes, setEdges]);
const addIndividual = useCallback(() => {
const newId = `ind_${Date.now()}`;
const newNode: OntologyNode = {
id: newId,
type: "classNode",
position: { x: Math.random() * 400, y: Math.random() * 300 },
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
};
setNodes((nds) => [...nds, newNode]);
}, [setNodes]);
const addRestriction = useCallback(() => {
setDraftDiff((prev) => ({
...prev,
added_restrictions: [...prev.added_restrictions, { type: "someValuesFrom", value: "" }],
}));
}, []);
const addAxiom = useCallback(() => {
setDraftDiff((prev) => ({
...prev,
added_axioms: [...prev.added_axioms, { type: "subClassOf", value: "" }],
}));
}, []);
const autoLayout = useCallback(() => {
const layoutNodes = nodes.map((node, index) => ({
...node,
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
}));
setNodes(layoutNodes);
}, [nodes, setNodes]);
const saveDraft = useCallback(async () => {
if (!ontologyUri) {
alert("Please select an ontology first");
return;
}
setIsSaving(true);
try {
const response = await fetch("/api/ontology/draft", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ontology_uri: ontologyUri,
diff: draftDiff,
author: "user",
summary: "Visual editor changes",
}),
});
if (response.ok) {
const data = await response.json();
alert(`Draft saved: ${data.draft_id}`);
}
} catch (error) {
console.error("Failed to save draft:", error);
alert("Failed to save draft");
} finally {
setIsSaving(false);
}
}, [ontologyUri, draftDiff]);
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: OntologyNode) => {
event.preventDefault();
setSelectedElement(node);
setShowContext({ x: event.clientX, y: event.clientY, type: "node", element: node });
}, []);
const handleEdgeContextMenu = useCallback((event: React.MouseEvent, edge: OntologyEdge) => {
event.preventDefault();
setSelectedElement(edge);
setShowContext({ x: event.clientX, y: event.clientY, type: "edge", element: edge });
}, []);
const deleteSelected = useCallback(() => {
const target = showContext?.element ?? selectedElement;
if (target) {
if ("source" in target) {
setEdges((eds) => eds.filter((e) => e.id !== target.id));
setDraftDiff((prev) => ({
...prev,
removed_properties: [...prev.removed_properties, target.id],
}));
} else {
setNodes((nds) => nds.filter((n) => n.id !== target.id));
setDraftDiff((prev) => ({
...prev,
removed_classes: [...prev.removed_classes, target.id],
}));
}
setSelectedElement(null);
}
setShowContext(null);
}, [selectedElement, setNodes, setEdges, showContext]);
const renameSelected = useCallback(() => {
const target = showContext?.element ?? selectedElement;
if (target && !("source" in target)) {
const newLabel = prompt("Enter new name:", String(target.data.label ?? ""));
if (newLabel) {
setNodes((nds) =>
nds.map((n) => (n.id === target.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
);
setDraftDiff((prev) => ({
...prev,
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
}));
}
}
setShowContext(null);
}, [selectedElement, setNodes, showContext]);
useEffect(() => {
const handleClick = () => setShowContext(null);
window.addEventListener("click", handleClick);
return () => window.removeEventListener("click", handleClick);
}, []);
const toolbarStyle: React.CSSProperties = {
display: "flex",
gap: "8px",
padding: "12px 16px",
background: "rgba(3, 9, 18, 0.92)",
borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
flexWrap: "wrap",
};
const toolbarButtonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.18)",
background: "rgba(74, 163, 255, 0.08)",
color: "#ebf3ff",
fontSize: "12px",
fontWeight: "600",
cursor: "pointer",
transition: "160ms ease",
};
const selectStyle: React.CSSProperties = {
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.18)",
background: "rgba(3, 9, 18, 0.88)",
color: "#ebf3ff",
fontSize: "12px",
minWidth: "260px",
};
const contextMenuStyle: React.CSSProperties = {
position: "fixed",
background: "rgba(9, 19, 34, 0.95)",
border: "1px solid rgba(127, 208, 255, 0.3)",
borderRadius: "8px",
padding: "8px 0",
minWidth: "180px",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
zIndex: 1000,
};
const contextItemStyle: React.CSSProperties = {
padding: "8px 16px",
display: "flex",
alignItems: "center",
gap: "10px",
color: "#ebf3ff",
fontSize: "13px",
cursor: "pointer",
transition: "160ms ease",
};
const detailPanelStyle: React.CSSProperties = {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "320px",
background: "rgba(9, 19, 34, 0.95)",
borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
padding: "20px",
overflow: "auto",
backdropFilter: "blur(18px)",
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#07111f" }}>
<div style={toolbarStyle}>
<select
aria-label="Active ontology"
value={ontologyUri}
onChange={(event) => setOntologyUri(event.target.value)}
style={selectStyle}
>
<option value="">Select ontology...</option>
{registry.map((entry) => (
<option key={entry.uri} value={entry.uri}>
{entry.name || entry.uri}
</option>
))}
</select>
<button style={toolbarButtonStyle} onClick={addClass}>
<Plus size={14} />
Add Class
</button>
<button style={toolbarButtonStyle} onClick={addProperty} disabled={nodes.length < 2}>
<GitBranch size={14} />
Add Property
</button>
<button style={toolbarButtonStyle} onClick={addIndividual}>
<User size={14} />
Add Individual
</button>
<button style={toolbarButtonStyle} onClick={addRestriction}>
<Shield size={14} />
Add Restriction
</button>
<button style={toolbarButtonStyle} onClick={addAxiom}>
<FileText size={14} />
Add Axiom
</button>
<button style={toolbarButtonStyle} onClick={autoLayout}>
<Layout size={14} />
Auto Layout
</button>
<div style={{ flex: 1 }} />
<button style={toolbarButtonStyle} onClick={saveDraft} disabled={isSaving}>
<Send size={14} />
{isSaving ? "Saving..." : "Propose"}
</button>
</div>
<div style={{ flex: 1, position: "relative" }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={(_, node) => setSelectedElement(node)}
onEdgeClick={(_, edge) => setSelectedElement(edge)}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
nodeTypes={nodeTypes}
fitView
style={{ background: "#07111f" }}
>
<Background color="#1a2d3d" gap={20} />
<Controls />
<MiniMap nodeColor="#4aa3ff" maskColor="rgba(0,0,0,0.6)" />
</ReactFlow>
{showContext && (
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
<div style={contextItemStyle} onClick={renameSelected}>
<Pencil size={14} />
Rename
</div>
<div style={contextItemStyle} onClick={deleteSelected}>
<Trash2 size={14} />
Delete
</div>
</div>
)}
{selectedElement && (
<div style={detailPanelStyle}>
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
{"source" in selectedElement ? "Property Details" : "Class Details"}
</h3>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
ID
</label>
<div style={{ color: "#ebf3ff", fontSize: "13px", wordBreak: "break-all" }}>
{selectedElement.id}
</div>
</div>
{!("source" in selectedElement) && (
<>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Label
</label>
<input
type="text"
value={String(selectedElement.data.label ?? "")}
onChange={(e) => {
setNodes((nds) =>
nds.map((n) =>
n.id === selectedElement.id
? { ...n, data: { ...n.data, label: e.target.value } }
: n
)
);
setDraftDiff((prev) => ({
...prev,
modified_classes: {
...prev.modified_classes,
[selectedElement.id]: { label: e.target.value },
},
}));
}}
style={{
width: "100%",
padding: "8px",
borderRadius: "6px",
border: "1px solid rgba(127, 208, 255, 0.2)",
background: "rgba(3, 9, 18, 0.8)",
color: "#ebf3ff",
fontSize: "13px",
}}
/>
</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Type
</label>
<div style={{ color: "#ebf3ff", fontSize: "13px" }}>
{selectedElement.data.type || "owl:Class"}
</div>
</div>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -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,413 @@
import { useCallback, useEffect, useState } from "react";
import {
GitMerge,
CheckCircle,
XCircle,
AlertCircle,
Send,
MessageSquare,
User,
Clock,
Plus,
Minus,
Edit,
} from "lucide-react";
interface Proposal {
proposal_id: string;
draft_id: string;
ontology_uri: string;
summary: string;
author: string;
reviewer: string | null;
state: "draft" | "proposed" | "approved" | "published" | "rejected";
impact_analysis: Record<string, any>;
shacl_validation: Record<string, any>;
created_at: string;
updated_at: string;
comments: Record<string, any>[];
}
interface DiffChange {
type: "added" | "removed" | "modified";
element: string;
details?: Record<string, any>;
}
export function ProposalReview({ proposalId }: { proposalId: string }) {
const [proposal, setProposal] = useState<Proposal | null>(null);
const [diff, setDiff] = useState<DiffChange[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [commentText, setCommentText] = useState("");
const loadProposal = useCallback(async () => {
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}`);
if (response.ok) {
const data = await response.json();
setProposal(data);
generateDiff(data);
}
} catch (error) {
console.error("Failed to load proposal:", error);
}
}, [proposalId]);
const generateDiff = useCallback((prop: Proposal) => {
const changes: DiffChange[] = [];
// Generate diff from impact analysis
if (prop.impact_analysis) {
if (prop.impact_analysis.class_adds > 0) {
changes.push({ type: "added", element: `Classes (${prop.impact_analysis.class_adds})` });
}
if (prop.impact_analysis.class_removals > 0) {
changes.push({ type: "removed", element: `Classes (${prop.impact_analysis.class_removals})` });
}
if (prop.impact_analysis.property_changes > 0) {
changes.push({ type: "modified", element: `Properties (${prop.impact_analysis.property_changes})` });
}
if (prop.impact_analysis.restriction_changes > 0) {
changes.push({ type: "modified", element: `Restrictions (${prop.impact_analysis.restriction_changes})` });
}
}
setDiff(changes);
}, []);
useEffect(() => {
loadProposal();
}, [loadProposal]);
const addComment = useCallback(async () => {
if (!selectedElement || !commentText || !proposal) return;
try {
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/comment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
element_uri: selectedElement,
text: commentText,
author: "user",
}),
});
if (response.ok) {
setCommentText("");
loadProposal();
}
} catch (error) {
console.error("Failed to add comment:", error);
alert("Failed to add comment");
}
}, [selectedElement, commentText, proposal, loadProposal]);
const approveProposal = useCallback(async () => {
if (!proposal) return;
try {
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/approve`, {
method: "POST",
});
if (response.ok) {
alert("Proposal approved");
loadProposal();
}
} catch (error) {
console.error("Failed to approve proposal:", error);
alert("Failed to approve proposal");
}
}, [proposal, loadProposal]);
const rejectProposal = useCallback(async () => {
if (!proposal) return;
try {
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/reject`, {
method: "POST",
});
if (response.ok) {
alert("Proposal rejected");
loadProposal();
}
} catch (error) {
console.error("Failed to reject proposal:", error);
alert("Failed to reject proposal");
}
}, [proposal, loadProposal]);
const publishProposal = useCallback(async () => {
if (!proposal) return;
try {
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/publish`, {
method: "POST",
});
if (response.ok) {
alert("Proposal published");
loadProposal();
}
} catch (error) {
console.error("Failed to publish proposal:", error);
alert("Failed to publish proposal");
}
}, [proposal, loadProposal]);
const getChangeIcon = (type: string) => {
switch (type) {
case "added":
return <Plus size={14} color="#4cc38a" />;
case "removed":
return <Minus size={14} color="#ff6b6b" />;
case "modified":
return <Edit size={14} color="#f2b66d" />;
default:
return null;
}
};
const getStateIcon = (state: string) => {
switch (state) {
case "published":
return <CheckCircle size={20} color="#4cc38a" />;
case "approved":
return <CheckCircle size={20} color="#4aa3ff" />;
case "rejected":
return <XCircle size={20} color="#ff6b6b" />;
case "proposed":
return <AlertCircle size={20} color="#f2b66d" />;
default:
return <Clock size={20} color="#8fa8c6" />;
}
};
const containerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
background: "#07111f",
padding: "20px",
overflow: "auto",
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
paddingBottom: "16px",
borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
};
const titleStyle: React.CSSProperties = {
margin: 0,
color: "#ebf3ff",
fontSize: "20px",
fontWeight: "700",
};
const contentStyle: React.CSSProperties = {
display: "flex",
gap: "20px",
flex: 1,
minHeight: 0,
};
const diffPanelStyle: React.CSSProperties = {
flex: 1,
background: "rgba(9, 19, 34, 0.8)",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.12)",
padding: "16px",
overflow: "auto",
};
const commentsPanelStyle: React.CSSProperties = {
width: "320px",
background: "rgba(9, 19, 34, 0.8)",
borderRadius: "8px",
border: "1px solid rgba(127, 208, 255, 0.12)",
padding: "16px",
display: "flex",
flexDirection: "column",
};
const diffItemStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "10px 12px",
borderRadius: "6px",
background: "rgba(3, 9, 18, 0.6)",
marginBottom: "8px",
cursor: "pointer",
transition: "160ms ease",
};
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "6px",
border: "1px solid rgba(127, 208, 255, 0.2)",
background: "rgba(74, 163, 255, 0.1)",
color: "#ebf3ff",
fontSize: "12px",
fontWeight: "600",
cursor: "pointer",
transition: "160ms ease",
};
const textareaStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
borderRadius: "6px",
border: "1px solid rgba(127, 208, 255, 0.2)",
background: "rgba(3, 9, 18, 0.8)",
color: "#ebf3ff",
fontSize: "13px",
resize: "vertical",
minHeight: "80px",
};
if (!proposal) {
return (
<div style={containerStyle}>
<div style={{ color: "#8fa8c6", fontSize: "14px" }}>Loading proposal...</div>
</div>
);
}
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
{getStateIcon(proposal.state)}
<div>
<h1 style={titleStyle}>{proposal.summary}</h1>
<div style={{ color: "#8fa8c6", fontSize: "12px", marginTop: "4px" }}>
{proposal.author} {new Date(proposal.created_at).toLocaleString()}
</div>
</div>
</div>
<div style={{ display: "flex", gap: "8px" }}>
{proposal.state === "proposed" && (
<>
<button style={buttonStyle} onClick={approveProposal}>
<CheckCircle size={12} />
Approve
</button>
<button style={buttonStyle} onClick={rejectProposal}>
<XCircle size={12} />
Reject
</button>
</>
)}
{proposal.state === "approved" && (
<button style={buttonStyle} onClick={publishProposal}>
<Send size={12} />
Publish
</button>
)}
</div>
</div>
<div style={contentStyle}>
<div style={diffPanelStyle}>
<h2 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "14px", fontWeight: "600" }}>
<GitMerge size={16} style={{ marginRight: "8px", verticalAlign: "middle" }} />
Diff Viewer
</h2>
{diff.length === 0 ? (
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No changes detected</div>
) : (
diff.map((change, index) => (
<div
key={index}
style={{
...diffItemStyle,
border: selectedElement === change.element ? "1px solid rgba(74, 163, 255, 0.4)" : "1px solid transparent",
}}
onClick={() => setSelectedElement(change.element)}
>
{getChangeIcon(change.type)}
<div style={{ flex: 1 }}>
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
{change.element}
</div>
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
{change.type}
</div>
</div>
</div>
))
)}
<div style={{ marginTop: "20px", paddingTop: "16px", borderTop: "1px solid rgba(140, 192, 255, 0.12)" }}>
<h3 style={{ margin: "0 0 12px", color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
Impact Analysis
</h3>
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(proposal.impact_analysis, null, 2)}
</pre>
</div>
<div style={{ marginTop: "16px" }}>
<h3 style={{ margin: "0 0 12px", color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
SHACL Validation
</h3>
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(proposal.shacl_validation, null, 2)}
</pre>
</div>
</div>
<div style={commentsPanelStyle}>
<h2 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "14px", fontWeight: "600" }}>
<MessageSquare size={16} style={{ marginRight: "8px", verticalAlign: "middle" }} />
Comments ({proposal.comments.length})
</h2>
<div style={{ flex: 1, overflow: "auto", marginBottom: "12px" }}>
{proposal.comments.length === 0 ? (
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No comments yet</div>
) : (
proposal.comments.map((comment) => (
<div
key={comment.id}
style={{
padding: "10px",
background: "rgba(3, 9, 18, 0.6)",
borderRadius: "6px",
marginBottom: "8px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
<User size={12} color="#8fa8c6" />
<span style={{ color: "#ebf3ff", fontSize: "12px", fontWeight: "600" }}>
{comment.author}
</span>
</div>
<div style={{ color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>{comment.text}</div>
<div style={{ color: "#5a7a9a", fontSize: "10px" }}>
{new Date(comment.created_at).toLocaleString()}
</div>
</div>
))
)}
</div>
{selectedElement && (
<div>
<textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
style={textareaStyle}
/>
<button style={buttonStyle} onClick={addComment} disabled={!commentText}>
<Send size={12} />
Add Comment
</button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -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,346 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import Editor, { type Monaco } from "@monaco-editor/react";
import { FileCode2, Loader2, Play, Shield, Wand2 } from "lucide-react";
import {
generateShacl,
loadOntologyRegistry,
loadShaclShapes,
validateShacl,
} from "./api";
import type { OntologyEntry, ShaclShapeSummary, ShaclValidationResponse } from "./types";
interface ShaclStudioProps {
onJumpToNode?: (nodeId: string) => void;
}
export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
const [selectedUri, setSelectedUri] = useState("");
const [shacl, setShacl] = useState("");
const [fullShacl, setFullShacl] = useState(""); // preserves complete Turtle across shape selections
const [shapes, setShapes] = useState<ShaclShapeSummary[]>([]);
const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null);
const [validation, setValidation] = useState<ShaclValidationResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
loadOntologyRegistry()
.then((entries) => {
if (cancelled) return;
setRegistry(entries);
setSelectedUri((current) => current || entries[0]?.uri || "");
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
});
return () => {
cancelled = true;
};
}, []);
const loadShapes = useCallback(async (uri: string) => {
if (!uri) return;
setLoading(true);
setError("");
try {
const data = await loadShaclShapes(uri);
setShapes(data.shapes);
const turtle = data.shacl_turtle;
setFullShacl(turtle);
setShacl((current) => current || turtle);
setSelectedShapeId(null);
setValidation(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load SHACL shapes.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setShacl("");
setFullShacl("");
setSelectedShapeId(null);
void loadShapes(selectedUri);
}, [selectedUri, loadShapes]);
const handleGenerate = useCallback(async () => {
if (!selectedUri) return;
setLoading(true);
setError("");
try {
const data = await generateShacl(selectedUri, "strict");
setFullShacl(data.shacl_turtle);
setShacl(data.shacl_turtle);
setSelectedShapeId(null);
const shapeData = await loadShaclShapes(selectedUri);
setShapes(shapeData.shapes);
setValidation(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not generate SHACL.");
} finally {
setLoading(false);
}
}, [selectedUri]);
const handleSelectShape = useCallback((shapeId: string) => {
setSelectedShapeId(shapeId);
// Extract the Turtle block for this shape from the full SHACL so the editor
// pre-populates with the selected shape's definition.
const src = fullShacl || shacl;
const normalised = src.replace(/\r\n/g, "\n");
// Split on blank lines to isolate statement groups.
const blocks = normalised.split(/\n{2,}/).filter((b) => b.trim());
const match = blocks.find((b) => {
const first = b.trimStart();
return first.startsWith(shapeId + " ") || first.startsWith(shapeId + "\n") || first.startsWith(shapeId + "\t");
});
if (match) {
setShacl(match.trim());
}
}, [fullShacl, shacl]);
const handleShowAllShapes = useCallback(() => {
setSelectedShapeId(null);
setShacl(fullShacl);
}, [fullShacl]);
const handleValidate = async () => {
if (!selectedUri || !shacl.trim()) return;
setLoading(true);
setError("");
try {
setValidation(await validateShacl(selectedUri, shacl));
} catch (err) {
setError(err instanceof Error ? err.message : "Could not validate SHACL.");
} finally {
setLoading(false);
}
};
const groupedShapes = useMemo(() => {
const groups = new Map<string, ShaclShapeSummary[]>();
for (const shape of shapes) {
const key = shape.target_class || "Untargeted shapes";
groups.set(key, [...(groups.get(key) || []), shape]);
}
return Array.from(groups.entries());
}, [shapes]);
const beforeMount = useCallback((monaco: Monaco) => {
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
monaco.languages.setMonarchTokensProvider("turtle", {
keywords: ["@prefix", "@base", "a"],
tokenizer: {
root: [
[/#[^\n]*/, "comment"],
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
[/"""[\s\S]*?"""/, "string"],
[/<[^>]*>/, "type.identifier"],
[/\b(?:@prefix|@base|a)\b/, "keyword"],
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
[/[;,.]/, "delimiter"],
[/\d+(?:\.\d+)?/, "number"],
],
},
});
}
monaco.editor.defineTheme("shacl-dark", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "keyword", foreground: "9ee8d7" },
{ token: "string", foreground: "f2b66d" },
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
{ token: "type.identifier", foreground: "7ce7d3" },
{ token: "variable", foreground: "d2a8ff" },
{ token: "namespace", foreground: "a5d6ff" },
{ token: "number", foreground: "79c0ff" },
{ token: "delimiter", foreground: "8fa8c6" },
],
colors: {
"editor.background": "#050b13",
"editor.foreground": "#d7e7f8",
"editorLineNumber.foreground": "#41536b",
},
});
}, []);
return (
<div style={pageStyle}>
<section style={heroStyle}>
<div>
<div style={kickerStyle}><Shield size={14} /> SHACL Studio</div>
<h2 style={titleStyle}>Generate, edit, and validate shapes</h2>
<p style={textStyle}>
Create strict SHACL Turtle from ontology structure, inspect shape targets,
run validation, and jump from violations back into the graph.
</p>
</div>
<div style={selectorShellStyle}>
<label style={labelStyle}>Ontology</label>
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
</select>
</div>
</section>
{error ? <div style={errorStyle}>{error}</div> : null}
<div style={gridStyle}>
<section style={cardStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>Shape library</h3>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={countBadgeStyle}>{shapes.length} shapes</span>
{selectedShapeId ? (
<button style={smallButtonStyle} onClick={handleShowAllShapes}>View all</button>
) : null}
</div>
</div>
<div style={shapeListStyle}>
{groupedShapes.map(([target, items]) => (
<div key={target} style={shapeGroupStyle}>
<div style={shapeTargetStyle}>{target}</div>
{items.map((shape) => {
const isSelected = selectedShapeId === shape.id;
return (
<button
key={shape.id}
style={{
...shapeRowStyle,
cursor: "pointer",
background: isSelected ? "rgba(124,231,211,0.1)" : "rgba(255,255,255,0.03)",
border: isSelected ? "1px solid rgba(124,231,211,0.35)" : "1px solid rgba(127,208,255,0.08)",
textAlign: "left",
width: "100%",
}}
onClick={() => handleSelectShape(shape.id)}
title="Click to load this shape into the editor"
>
<FileCode2 size={14} color={isSelected ? "#7ce7d3" : "#9ee8d7"} />
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{shape.id}</div>
<div style={mutedStyle}>
{shape.constraint_count} constraints
{shape.constraints.length ? ` · ${shape.constraints.join(", ")}` : ""}
</div>
</div>
<span style={violationBadgeStyle}>{shape.violation_count}</span>
</button>
);
})}
</div>
))}
{!shapes.length ? <p style={mutedStyle}>No shapes generated yet.</p> : null}
</div>
</section>
<section style={editorShellStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>
{selectedShapeId ? selectedShapeId : "Turtle shape editor"}
</h3>
<div style={{ display: "flex", gap: 8 }}>
<button style={secondaryButtonStyle} disabled={loading} onClick={handleGenerate}><Wand2 size={14} /> Generate strict</button>
<button style={primaryButtonStyle} disabled={loading || !shacl.trim()} onClick={handleValidate}>
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
Validate
</button>
</div>
</div>
<div style={editorFrameStyle}>
<Editor
height="100%"
language="turtle"
theme="shacl-dark"
beforeMount={beforeMount}
value={shacl}
onChange={(value) => setShacl(value || "")}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "JetBrains Mono, monospace",
wordWrap: "on",
scrollBeyondLastLine: false,
}}
/>
</div>
</section>
</div>
<section style={cardStyle}>
<div style={panelHeaderStyle}>
<h3 style={sectionTitleStyle}>Validation report</h3>
{validation ? <span style={validationBadgeStyle(validation.status, validation.conforms)}>{validation.status}{validation.conforms ? " · conforms" : ""}</span> : null}
</div>
{validation ? (
<>
<p style={textStyle}>{validation.message}</p>
<div style={shapeListStyle}>
{validation.violations.map((violation, index) => {
const nodeId = violation.focus_node || violation.node;
return (
<div key={`${violation.node}-${violation.path}-${index}`} style={violationRowStyle}>
<div>
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{violation.message}</div>
<div style={mutedStyle}>{violation.severity} {violation.path ? `· ${violation.path}` : ""}</div>
{nodeId ? <div style={monoStyle}>{nodeId}</div> : null}
</div>
{nodeId ? (
<button style={smallButtonStyle} onClick={() => onJumpToNode?.(nodeId)}>
Jump to Node
</button>
) : null}
</div>
);
})}
{!validation.violations.length ? <p style={mutedStyle}>No validation violations returned.</p> : null}
</div>
</>
) : (
<p style={mutedStyle}>Generate or edit SHACL Turtle, then run validation.</p>
)}
</section>
</div>
);
}
function validationBadgeStyle(status: string, conforms: boolean): CSSProperties {
const color = status === "unavailable" ? "#f2b66d" : conforms ? "#7ce7d3" : "#ff9daf";
return { color, border: `1px solid ${color}35`, background: `${color}14`, borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900, textTransform: "uppercase" };
}
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 680 };
const selectorShellStyle: CSSProperties = { minWidth: 320 };
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "360px minmax(0, 1fr)", gap: 16, minHeight: 560 };
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
const editorShellStyle: CSSProperties = { ...cardStyle, display: "flex", flexDirection: "column", minHeight: 560 };
const panelHeaderStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 12 };
const sectionTitleStyle: CSSProperties = { margin: 0, color: "#ebf3ff", fontSize: 16 };
const countBadgeStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.2)", borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900 };
const shapeListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
const shapeGroupStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6 };
const shapeTargetStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontWeight: 900, textTransform: "uppercase", letterSpacing: "0.08em" };
const shapeRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "18px 1fr auto", gap: 10, padding: 10, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const violationBadgeStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
const editorFrameStyle: CSSProperties = { flex: 1, minHeight: 0, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 16, overflow: "hidden" };
const primaryButtonStyle: CSSProperties = { border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "9px 11px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 };
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
const violationRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto", gap: 12, padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
const smallButtonStyle: CSSProperties = { border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 12, lineHeight: 1.5 };
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
@@ -0,0 +1,475 @@
import { useCallback, useEffect, useState } from "react";
import {
Layers,
GitMerge,
Clock,
FileText,
CheckCircle,
XCircle,
AlertCircle,
Send,
X,
ArrowRight,
Scale,
} from "lucide-react";
interface VersionEntry {
version_id: string;
ontology_uri: string;
state: "draft" | "published";
author: string;
date: string;
diff_summary: Record<string, any>;
}
interface Proposal {
proposal_id: string;
draft_id: string;
ontology_uri: string;
summary: string;
author: string;
reviewer: string | null;
state: "draft" | "proposed" | "approved" | "published" | "rejected";
impact_analysis: Record<string, any>;
shacl_validation: Record<string, any>;
created_at: string;
updated_at: string;
comments: Record<string, any>[];
}
export function VersionsTab() {
const [ontologyUri, setOntologyUri] = useState<string>("");
const [versions, setVersions] = useState<VersionEntry[]>([]);
const [proposals, setProposals] = useState<Proposal[]>([]);
const [selectedProposal, setSelectedProposal] = useState<Proposal | null>(null);
const [showProposalModal, setShowProposalModal] = useState(false);
const [showCompareModal, setShowCompareModal] = useState(false);
const [comparePair, setComparePair] = useState<{ v1: string; v2: string } | null>(null);
const [compareResult, setCompareResult] = useState<Record<string, any> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const loadVersions = useCallback(async () => {
if (!ontologyUri) return;
try {
const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}`);
if (response.ok) {
const data = await response.json();
setVersions(data);
}
} catch (error) {
console.error("Failed to load versions:", error);
}
}, [ontologyUri]);
const loadProposals = useCallback(async () => {
try {
const response = await fetch("/api/ontology/proposals");
if (response.ok) {
const data = await response.json();
setProposals(data);
}
} catch (error) {
console.error("Failed to load proposals:", error);
}
}, []);
useEffect(() => {
loadVersions();
loadProposals();
}, [loadVersions, loadProposals]);
const approveProposal = useCallback(async (proposalId: string) => {
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/approve`, {
method: "POST",
});
if (response.ok) {
alert("Proposal approved");
loadProposals();
}
} catch (error) {
console.error("Failed to approve proposal:", error);
alert("Failed to approve proposal");
}
}, [loadProposals]);
const rejectProposal = useCallback(async (proposalId: string) => {
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/reject`, {
method: "POST",
});
if (response.ok) {
alert("Proposal rejected");
loadProposals();
}
} catch (error) {
console.error("Failed to reject proposal:", error);
alert("Failed to reject proposal");
}
}, [loadProposals]);
const publishProposal = useCallback(async (proposalId: string) => {
try {
const response = await fetch(`/api/ontology/proposals/${proposalId}/publish`, {
method: "POST",
});
if (response.ok) {
alert("Proposal published");
loadProposals();
loadVersions();
}
} catch (error) {
console.error("Failed to publish proposal:", error);
alert("Failed to publish proposal");
}
}, [loadProposals, loadVersions]);
const runVersionComparison = useCallback(async () => {
if (!comparePair || !ontologyUri) return;
setIsLoading(true);
try {
const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}/compare`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
version1: comparePair.v1,
version2: comparePair.v2,
}),
});
if (response.ok) {
const data = await response.json();
setCompareResult(data);
}
} catch (error) {
console.error("Failed to compare versions:", error);
alert("Failed to compare versions");
} finally {
setIsLoading(false);
}
}, [comparePair, ontologyUri]);
const getStateIcon = (state: string) => {
switch (state) {
case "published":
return <CheckCircle size={16} color="#4cc38a" />;
case "approved":
return <CheckCircle size={16} color="#4aa3ff" />;
case "rejected":
return <XCircle size={16} color="#ff6b6b" />;
case "proposed":
return <AlertCircle size={16} color="#f2b66d" />;
default:
return <Clock size={16} color="#8fa8c6" />;
}
};
const containerStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
background: "#07111f",
padding: "20px",
overflow: "auto",
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
};
const titleStyle: React.CSSProperties = {
margin: 0,
color: "#ebf3ff",
fontSize: "20px",
fontWeight: "700",
};
const sectionStyle: React.CSSProperties = {
marginBottom: "24px",
};
const sectionTitleStyle: React.CSSProperties = {
margin: "0 0 12px",
color: "#ebf3ff",
fontSize: "14px",
fontWeight: "600",
display: "flex",
alignItems: "center",
gap: "8px",
};
const listStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: "8px",
};
const itemStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "12px 16px",
borderRadius: "8px",
background: "rgba(9, 19, 34, 0.8)",
border: "1px solid rgba(127, 208, 255, 0.12)",
transition: "160ms ease",
};
const modalOverlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
};
const modalStyle: React.CSSProperties = {
background: "rgba(9, 19, 34, 0.95)",
border: "1px solid rgba(127, 208, 255, 0.2)",
borderRadius: "12px",
padding: "24px",
minWidth: "480px",
maxWidth: "640px",
maxHeight: "80vh",
overflow: "auto",
backdropFilter: "blur(18px)",
};
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "6px",
border: "1px solid rgba(127, 208, 255, 0.2)",
background: "rgba(74, 163, 255, 0.1)",
color: "#ebf3ff",
fontSize: "12px",
fontWeight: "600",
cursor: "pointer",
transition: "160ms ease",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
borderRadius: "6px",
border: "1px solid rgba(127, 208, 255, 0.2)",
background: "rgba(3, 9, 18, 0.8)",
color: "#ebf3ff",
fontSize: "13px",
marginBottom: "12px",
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<h1 style={titleStyle}>Versions & Change Proposals</h1>
<input
type="text"
placeholder="Ontology URI"
value={ontologyUri}
onChange={(e) => setOntologyUri(e.target.value)}
style={{ ...inputStyle, width: "300px", marginBottom: 0 }}
/>
</div>
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>
<Layers size={16} />
Version History
</h2>
<div style={listStyle}>
{versions.length === 0 ? (
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No versions found</div>
) : (
versions.map((version) => (
<div key={version.version_id} style={itemStyle}>
{getStateIcon(version.state)}
<div style={{ flex: 1 }}>
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
{version.version_id}
</div>
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
{version.author} {new Date(version.date).toLocaleDateString()}
</div>
</div>
<button
style={buttonStyle}
onClick={() => {
setComparePair({ v1: version.version_id, v2: versions[0]?.version_id || "" });
setShowCompareModal(true);
}}
>
<ArrowRight size={12} />
Compare
</button>
</div>
))
)}
</div>
</div>
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>
<GitMerge size={16} />
Change Proposals
</h2>
<div style={listStyle}>
{proposals.length === 0 ? (
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No proposals found</div>
) : (
proposals.map((proposal) => (
<div key={proposal.proposal_id} style={itemStyle}>
{getStateIcon(proposal.state)}
<div style={{ flex: 1 }}>
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
{proposal.summary}
</div>
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
{proposal.author} {new Date(proposal.created_at).toLocaleDateString()}
</div>
</div>
{proposal.state === "proposed" && (
<div style={{ display: "flex", gap: "6px" }}>
<button style={buttonStyle} onClick={() => approveProposal(proposal.proposal_id)}>
<CheckCircle size={12} />
Approve
</button>
<button style={buttonStyle} onClick={() => rejectProposal(proposal.proposal_id)}>
<XCircle size={12} />
Reject
</button>
</div>
)}
{proposal.state === "approved" && (
<button style={buttonStyle} onClick={() => publishProposal(proposal.proposal_id)}>
<Send size={12} />
Publish
</button>
)}
<button
style={buttonStyle}
onClick={() => {
setSelectedProposal(proposal);
setShowProposalModal(true);
}}
>
<FileText size={12} />
Details
</button>
</div>
))
)}
</div>
</div>
{showProposalModal && selectedProposal && (
<div style={modalOverlayStyle} onClick={() => setShowProposalModal(false)}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: "16px" }}>Proposal Details</h3>
<button onClick={() => setShowProposalModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
<X size={18} />
</button>
</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Summary
</label>
<div style={{ color: "#ebf3ff", fontSize: "13px" }}>{selectedProposal.summary}</div>
</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
State
</label>
<div style={{ display: "flex", alignItems: "center", gap: "6px", color: "#ebf3ff", fontSize: "13px" }}>
{getStateIcon(selectedProposal.state)}
{selectedProposal.state}
</div>
</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Impact Analysis
</label>
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(selectedProposal.impact_analysis, null, 2)}
</pre>
</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
SHACL Validation
</label>
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(selectedProposal.shacl_validation, null, 2)}
</pre>
</div>
<div>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Comments ({selectedProposal.comments.length})
</label>
<div style={{ maxHeight: "120px", overflow: "auto" }}>
{selectedProposal.comments.map((comment) => (
<div key={comment.id} style={{ padding: "8px", background: "rgba(3, 9, 18, 0.6)", borderRadius: "4px", marginBottom: "6px" }}>
<div style={{ color: "#ebf3ff", fontSize: "12px", fontWeight: "600" }}>{comment.author}</div>
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>{comment.text}</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
{showCompareModal && comparePair && (
<div style={modalOverlayStyle} onClick={() => setShowCompareModal(false)}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: "16px" }}>Compare Versions</h3>
<button onClick={() => setShowCompareModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
<X size={18} />
</button>
</div>
<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
<div style={{ flex: 1 }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Version 1
</label>
<input
type="text"
value={comparePair.v1}
onChange={(e) => setComparePair({ ...comparePair, v1: e.target.value })}
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
Version 2
</label>
<input
type="text"
value={comparePair.v2}
onChange={(e) => setComparePair({ ...comparePair, v2: e.target.value })}
style={inputStyle}
/>
</div>
</div>
<button style={buttonStyle} onClick={runVersionComparison} disabled={isLoading}>
<Scale size={12} />
{isLoading ? "Comparing..." : "Compare"}
</button>
{compareResult && (
<pre style={{ marginTop: "16px", background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(compareResult, null, 2)}
</pre>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import type {
AlignmentRelation,
AlignmentSuggestion,
OntologyAlignment,
OntologyEntry,
OntologyHealthResponse,
ShaclGenerateResponse,
ShaclShapesResponse,
ShaclValidationResponse,
} from "./types";
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let detail = `Request failed with status ${response.status}`;
try {
const body = await response.json();
detail = body.detail || detail;
} catch {
// Keep the generic HTTP detail.
}
throw new Error(detail);
}
return response.json() as Promise<T>;
}
export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
}
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
}
export async function saveAlignment(payload: {
source_uri: string;
target_uri: string;
relation: AlignmentRelation;
confidence: number;
provenance?: string;
source?: string;
reviewer?: string;
}): Promise<OntologyAlignment> {
return parseResponse<OntologyAlignment>(
await fetch("/api/ontology/alignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function removeAlignment(id: string): Promise<void> {
await parseResponse<{ status: string }>(
await fetch(`/api/ontology/alignments?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
);
}
export async function suggestAlignments(payload: {
source_ontology_uri?: string;
target_ontology_uri?: string;
threshold: number;
limit: number;
}): Promise<AlignmentSuggestion[]> {
return parseResponse<AlignmentSuggestion[]>(
await fetch("/api/ontology/suggest-alignments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
}
export async function loadOntologyHealth(uri: string): Promise<OntologyHealthResponse> {
return parseResponse<OntologyHealthResponse>(
await fetch(`/api/ontology/health?uri=${encodeURIComponent(uri)}`),
);
}
export async function generateShacl(uri: string, qualityTier: "standard" | "strict" = "strict"): Promise<ShaclGenerateResponse> {
return parseResponse<ShaclGenerateResponse>(
await fetch("/api/ontology/shacl/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, quality_tier: qualityTier }),
}),
);
}
export async function loadShaclShapes(uri: string): Promise<ShaclShapesResponse> {
return parseResponse<ShaclShapesResponse>(
await fetch(`/api/ontology/shacl/shapes?uri=${encodeURIComponent(uri)}`),
);
}
export async function validateShacl(uri: string, shaclTurtle: string): Promise<ShaclValidationResponse> {
return parseResponse<ShaclValidationResponse>(
await fetch("/api/ontology/shacl/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uri, shacl_turtle: shaclTurtle }),
}),
);
}
@@ -0,0 +1,271 @@
import { useCallback, useEffect, useState } from "react";
import {
BookMarked,
GitMerge,
HeartPulse,
Layers,
Shield,
Sliders,
} from "lucide-react";
import { AlignmentsTab } from "./AlignmentsTab";
import { HealthTab } from "./HealthTab";
import { OntologyManager } from "./OntologyManager";
import { OntologyEditor } from "./OntologyEditor";
import { ShaclStudio } from "./ShaclStudio";
import { VersionsTab } from "./VersionsTab";
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>
);
}
interface OntologyWorkspaceProps {
onJumpToGraphNode?: (nodeId: string) => void;
}
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
useEffect(() => {
writeTabParam(activeTab);
}, [activeTab]);
const handleTabChange = useCallback((tab: OntologyHubTab) => {
setActiveTab(tab);
}, []);
const handleFixInEditor = useCallback((entityUri: string) => {
const params = new URLSearchParams(window.location.search);
params.set(TAB_PARAM, "editor");
params.set("ontologyEntity", entityUri);
window.history.replaceState(null, "", `?${params.toString()}`);
setActiveTab("editor");
}, []);
const renderTab = () => {
switch (activeTab) {
case "registry":
return <OntologyManager />;
case "editor":
return <OntologyEditor />;
case "versions":
return <VersionsTab />;
case "alignments":
return <AlignmentsTab />;
case "health":
return <HealthTab onFixInEditor={handleFixInEditor} />;
case "shacl":
return <ShaclStudio onJumpToNode={onJumpToGraphNode} />;
}
};
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,
};
@@ -0,0 +1,119 @@
export 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[];
}
export type AlignmentRelation =
| "owl:equivalentClass"
| "owl:equivalentProperty"
| "skos:exactMatch"
| "skos:closeMatch"
| "skos:broadMatch"
| "skos:narrowMatch"
| "skos:relatedMatch";
export interface OntologyAlignment {
id: string;
source_uri: string;
source_label: string;
target_uri: string;
target_label: string;
relation: AlignmentRelation;
predicate_uri: string;
confidence: number;
provenance?: string;
source?: string;
reviewer?: string;
created_at: string;
updated_at: string;
}
export interface AlignmentSuggestion {
source_uri: string;
source_label: string;
target_uri: string;
target_label: string;
relation: AlignmentRelation;
score: number;
label_similarity: number;
embedding_similarity?: number | null;
reason: string;
}
export interface HealthDimension {
key: string;
label: string;
score: number;
status: "ok" | "warning" | "critical" | "unavailable";
detail: string;
}
export interface HealthIssue {
id: string;
severity: "info" | "warning" | "critical";
category: string;
entity_uri?: string;
entity_label?: string;
message: string;
action?: string;
}
export interface OntologyHealthResponse {
uri: string;
name: string;
total_score: number;
dimensions: HealthDimension[];
issues: HealthIssue[];
generated_at: string;
}
export interface ShaclShapeSummary {
id: string;
target_class?: string;
constraint_count: number;
constraints: string[];
violation_count: number;
}
export interface ShaclViolation {
node?: string;
path?: string;
severity: string;
message: string;
focus_node?: string;
source_shape?: string;
}
export interface ShaclGenerateResponse {
uri: string;
shacl_turtle: string;
shape_count: number;
generated_at: string;
}
export interface ShaclShapesResponse {
uri: string;
shapes: ShaclShapeSummary[];
shacl_turtle: string;
generated_at: string;
}
export interface ShaclValidationResponse {
uri?: string;
conforms: boolean;
status: "success" | "unavailable" | "error";
message: string;
violations: ShaclViolation[];
report_text?: string;
}
@@ -5,13 +5,33 @@ import {
batchMergeEdges,
batchMergeNodes,
clearGraph,
graph,
} from "../src/store/graphStore.ts";
import {
buildGraphAnalyticsSnapshot,
computeGraphAnalyticsBase,
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
import {
buildHeatmapRenderSnapshot,
buildStructuralDistanceSnapshot,
classifyFullGraphEdge,
checkGroupedViewAvailability,
mapFullEdgeClassToVisualState,
resolveDistanceEdgeStyle,
resolveDistanceNodeStyle,
resolveEdgeElementStyle,
resolveEdgeVisualState,
resolveDisplayGraph,
resolveGroupedDisplayNodeId,
resolveGroupedDisplayStateSnapshot,
summarizeDistanceBuckets,
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
import {
buildGraphStructureCurveCache,
evaluateGraphStructureLayerGate,
} from "../src/workspaces/GraphWorkspace/graphStructureLayer.ts";
import { GRAPH_THEME } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
import type { GraphDistanceVisualState, GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
function addNode(id: string, semanticGroup = "entity") {
batchMergeNodes([
@@ -33,6 +53,10 @@ function addNode(id: string, semanticGroup = "entity") {
]);
}
function setNodePosition(id: string, x: number, y: number) {
graph.mergeNodeAttributes(id, { x, y });
}
function addEdge(id: string, source: string, target: string, weight = 1) {
batchMergeEdges([
{
@@ -56,6 +80,785 @@ test.after(() => {
clearGraph();
});
const BASE_NODE_STYLE = {
color: "#63E6FF",
shellColor: "#63E6FF",
coreScale: 1,
size: 8,
forceLabel: false,
label: "node",
zIndex: 1,
hidden: false,
borderColor: "#63E6FF",
borderSize: 1,
nodeVariant: "default",
entityShape: "entity",
entityShapeKind: 0,
entityAspectRatio: 1,
showBadge: false,
showRing: false,
ringSize: 0,
showHalo: false,
haloColor: "transparent",
} as const;
const BASE_EDGE_STYLE = {
hidden: true,
color: "#334155",
size: 0.5,
zIndex: 0,
edgeVariant: "line",
arrowVisibilityPolicy: "hidden",
curveStrength: 0,
curvature: 0,
} as const;
function makeDistanceState(overrides: Partial<GraphDistanceVisualState>): GraphDistanceVisualState {
return {
mode: "off",
anchorNodeId: null,
anchorLabel: null,
maxHops: 2,
structuralDistances: {},
semanticScores: {},
semanticNeighborCount: 0,
status: "ready",
error: null,
...overrides,
};
}
test("buildStructuralDistanceSnapshot returns bounded BFS hop distances", () => {
addNode("anchor");
addNode("near");
addNode("far");
addNode("outside");
addNode("too-far");
addEdge("e-anchor-near", "anchor", "near");
addEdge("e-near-far", "near", "far");
addEdge("e-far-outside", "far", "outside");
addEdge("e-outside-too-far", "outside", "too-far");
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
assert.equal(distances.anchor, 0);
assert.equal(distances.near, 1);
assert.equal(distances.far, 2);
assert.equal(distances.outside, 3);
assert.equal(distances["too-far"], undefined);
});
test("summarizeDistanceBuckets reports local rings and outside count", () => {
const counts = summarizeDistanceBuckets({
anchor: 0,
one: 1,
two: 2,
three: 3,
}, 6);
assert.deepEqual(counts, {
anchor: 1,
oneHop: 1,
twoHop: 1,
threeHop: 1,
outside: 2,
});
});
test("buildHeatmapRenderSnapshot caps and deterministically samples large rings", () => {
addNode("anchor");
for (let index = 0; index < 130; index += 1) {
const nodeId = `one-${index}`;
addNode(nodeId);
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 7, labelPriority: index % 5 });
addEdge(`edge-anchor-${nodeId}`, "anchor", nodeId, index % 11);
}
for (let index = 0; index < 700; index += 1) {
const nodeId = `two-${index}`;
addNode(nodeId);
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 13, labelPriority: index % 3 });
addEdge(`edge-one-two-${index}`, `one-${index % 130}`, nodeId, index % 17);
}
for (let index = 0; index < 950; index += 1) {
const nodeId = `three-${index}`;
addNode(nodeId);
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 19, labelPriority: index % 4 });
addEdge(`edge-two-three-${index}`, `two-${index % 700}`, nodeId, index % 23);
}
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
const firstSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
const secondSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
assert.equal(firstSnapshot.ringCounts.anchor, 1);
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
assert.equal(firstSnapshot.saturationMode, "sampled");
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
});
test("resolveDistanceNodeStyle applies ego muting without mutating graph data", () => {
const state = makeDistanceState({
mode: "ego",
anchorNodeId: "anchor",
anchorLabel: "Anchor",
maxHops: 2,
structuralDistances: { anchor: 0, near: 1 },
});
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "anchor");
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "outside");
assert.equal(anchorStyle.forceLabel, true);
assert.equal(anchorStyle.label, "Anchor");
assert.ok(Number(anchorStyle.size) > BASE_NODE_STYLE.size);
assert.equal(outsideStyle.label, "");
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
});
test("resolveDistanceNodeStyle applies readable heatmap rings only when ready", () => {
const readyState = makeDistanceState({
mode: "heatmap",
anchorNodeId: "anchor",
maxHops: 3,
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
distanceCounts: {
anchor: 1,
oneHop: 1,
twoHop: 1,
threeHop: 1,
outside: 1,
},
});
const loadingState = makeDistanceState({ ...readyState, status: "loading" });
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "anchor");
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "one");
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "two");
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "three");
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "outside");
const loadingStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, loadingState, "near");
assert.notEqual(anchorStyle.color, oneHopStyle.color);
assert.notEqual(oneHopStyle.color, twoHopStyle.color);
assert.notEqual(twoHopStyle.color, threeHopStyle.color);
assert.ok(Number(anchorStyle.size) > Number(oneHopStyle.size));
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
assert.equal(outsideStyle.label, "");
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
assert.deepEqual(loadingStyle, {});
});
test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
const saturatedState = makeDistanceState({
mode: "heatmap",
anchorNodeId: "anchor",
maxHops: 3,
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
distanceCounts: {
anchor: 1,
oneHop: 32,
twoHop: 3350,
threeHop: 7412,
outside: 3280,
},
});
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "one");
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "two");
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "three");
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "outside");
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
assert.ok(Number(threeHopStyle.size) > Number(outsideStyle.size));
assert.equal(threeHopStyle.label, "");
});
test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring them", () => {
const sampledState = makeDistanceState({
mode: "heatmap",
anchorNodeId: "anchor",
maxHops: 3,
structuralDistances: { anchor: 0, rendered: 2, unsampled: 2 },
heatmapVisibleNodeIds: ["anchor", "rendered"],
distanceCounts: {
anchor: 1,
oneHop: 0,
twoHop: 2,
threeHop: 0,
outside: 0,
},
heatmapRenderedRingCounts: {
anchor: 1,
oneHop: 0,
twoHop: 1,
threeHop: 0,
outside: 0,
},
heatmapSaturationMode: "sampled",
});
const renderedStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "rendered");
const unsampledStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "unsampled");
assert.notEqual(renderedStyle.color, unsampledStyle.color);
assert.ok(Number(renderedStyle.size) > Number(unsampledStyle.size));
assert.equal(unsampledStyle.label, "");
});
test("resolveDistanceEdgeStyle reveals structural and semantic context edges", () => {
const structuralState = makeDistanceState({
mode: "structural",
anchorNodeId: "anchor",
maxHops: 2,
structuralDistances: { anchor: 0, near: 1 },
});
const semanticState = makeDistanceState({
mode: "semantic",
anchorNodeId: "anchor",
semanticScores: { semantic: 0.82 },
});
const structuralStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, structuralState, "anchor", "near");
const semanticStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "anchor", "semantic");
const unrelatedStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "near", "semantic");
assert.equal(structuralStyle.hidden, false);
assert.equal(semanticStyle.hidden, false);
assert.deepEqual(unrelatedStyle, {});
});
test("resolveDistanceEdgeStyle suppresses heatmap background edges but preserves context", () => {
const heatmapState = makeDistanceState({
mode: "heatmap",
anchorNodeId: "anchor",
maxHops: 3,
structuralDistances: { anchor: 0, one: 1 },
});
const backgroundStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "backbone");
const contextStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "local-context");
const pathStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "path");
assert.equal(backgroundStyle.hidden, true);
assert.deepEqual(contextStyle, {});
assert.deepEqual(pathStyle, {});
});
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
const uncappedState = resolveEdgeVisualState(
"edge-1",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(),
);
assert.equal(uncappedState, "muted");
const cappedState = resolveEdgeVisualState(
"edge-1",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(["edge-1"]),
);
assert.equal(cappedState, "selected");
});
test("resolveEdgeElementStyle applies full-graph LOD to directional background edges", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"default",
{
edgeType: "related_to",
weight: 1,
properties: {},
edgeVariant: "directional",
visualPriority: 0.1,
baseSize: 0.5,
},
"source",
"target",
"full",
"directional-low-priority",
);
assert.equal(style.hidden, true);
});
test("classifyFullGraphEdge applies deterministic priority order", () => {
const edgeClass = classifyFullGraphEdge(
"edge-priority",
"source",
"target",
"inspection",
"source",
"source",
"edge-priority",
new Set(["source", "target"]),
new Set(["edge-priority"]),
new Set(["edge-priority"]),
new Set(["edge-priority"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
);
assert.equal(edgeClass, "path");
const selectedClass = classifyFullGraphEdge(
"edge-priority",
"source",
"target",
"inspection",
"source",
"source",
"edge-priority",
new Set(["source", "target"]),
new Set(),
new Set(["edge-priority"]),
);
assert.equal(selectedClass, "selected");
});
test("classifyFullGraphEdge separates capped local context from muted hub edges", () => {
const mutedClass = classifyFullGraphEdge(
"hub-edge",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(),
);
assert.equal(mutedClass, "muted");
const localContextClass = classifyFullGraphEdge(
"hub-edge",
"hub",
"leaf",
"inspection",
null,
"hub",
"",
new Set(["hub", "leaf"]),
new Set(),
new Set(["hub-edge"]),
);
assert.equal(localContextClass, "local-context");
});
test("classifyFullGraphEdge marks curated bridge and backbone candidates", () => {
const bridgeClass = classifyFullGraphEdge(
"curated-bridge",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
new Set(["curated-bridge"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
);
assert.equal(bridgeClass, "bridge");
const backboneClass = classifyFullGraphEdge(
"curated-backbone",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
new Set(["curated-backbone"]),
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "target", semanticGroup: "gene", properties: {} },
);
assert.equal(backboneClass, "backbone");
});
test("classifyFullGraphEdge hides ordinary full-graph overview edges", () => {
const edgeClass = classifyFullGraphEdge(
"ordinary-edge",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
new Set(),
);
assert.equal(edgeClass, "hidden");
});
test("mapFullEdgeClassToVisualState renders curated backbone and bridge as backbone", () => {
assert.equal(
mapFullEdgeClassToVisualState("backbone", { hoveredNodeId: null, hasActiveInteraction: false }),
"backbone",
);
assert.equal(
mapFullEdgeClassToVisualState("bridge", { hoveredNodeId: null, hasActiveInteraction: false }),
"backbone",
);
assert.equal(
mapFullEdgeClassToVisualState("hidden", { hoveredNodeId: null, hasActiveInteraction: false }),
"inactive",
);
});
test("resolveEdgeElementStyle renders curated full-graph backbone quietly", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"curated-backbone-edge",
"backbone",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "line");
assert.match(style.color ?? "", /rgba\(.+,\s*0\.08\)/);
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.backboneMaxSize);
});
test("resolveEdgeElementStyle renders high-value bridge as a calm curved teal edge", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 4,
properties: {},
visualPriority: 0.95,
baseSize: 0.9,
edgeVariant: "line",
},
"source",
"target",
"full",
"curated-bridge-edge",
"bridge",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "curve");
assert.notEqual(style.type, "arrow");
assert.match(style.color ?? "", /rgba\(.+,\s*0\.14\)/);
assert.equal(style.curvature, GRAPH_THEME.edges.fullGraphStructure.bridgeCurveStrength);
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.bridgeMaxSize);
});
test("resolveEdgeElementStyle keeps low-priority bridge straight", () => {
const style = resolveEdgeElementStyle(
GRAPH_THEME,
"overview",
"backbone",
{
edgeType: "related_to",
weight: 1,
properties: {},
visualPriority: 0.2,
baseSize: 0.9,
edgeVariant: "line",
},
"source",
"target",
"full",
"low-value-bridge-edge",
"bridge",
);
assert.equal(style.hidden, false);
assert.equal(style.type, "line");
assert.equal(style.curvature, 0);
});
test("evaluateGraphStructureLayerGate enables only sparse settled full-graph structure", () => {
const counts: GraphFullEdgeClassCounts = {
hidden: 20,
backbone: 6,
bridge: 4,
"local-context": 0,
selected: 0,
path: 0,
muted: 0,
};
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "grouped",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "grouped",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "non-full-mode" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: true,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "layout-running" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 24,
counts: { ...counts, backbone: 18, bridge: 6 },
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: false, disabledReason: "enough-literal-edges" },
);
assert.deepEqual(
evaluateGraphStructureLayerGate({
mode: "auto",
viewMode: "full",
isLayoutRunning: false,
edgeDiagnostics: {
mode: "full",
zoomTier: "overview",
totalEdges: 30,
visibleEdges: 10,
counts,
updatedAt: 1,
},
minimumLiteralEdges: 24,
}),
{ enabled: true, disabledReason: null },
);
});
test("buildGraphStructureCurveCache prefers bridges, caps curves, and skips invalid endpoints", () => {
addNode("a", "gene");
addNode("b", "disease");
addNode("c", "gene");
addNode("d", "compound");
setNodePosition("a", 0, 0);
setNodePosition("b", 100, 0);
setNodePosition("c", 0, 100);
setNodePosition("d", Number.NaN, 100);
addEdge("backbone-1", "a", "c", 1);
graph.mergeEdgeAttributes("backbone-1", { visualPriority: 1 });
addEdge("bridge-1", "a", "b", 0.2);
graph.mergeEdgeAttributes("bridge-1", { visualPriority: 0.1 });
addEdge("selected-1", "b", "c", 1);
graph.mergeEdgeAttributes("selected-1", { visualPriority: 1 });
addEdge("invalid-bridge", "a", "d", 1);
graph.mergeEdgeAttributes("invalid-bridge", { visualPriority: 1 });
const edgeClasses = new Map<string, GraphFullEdgeClass>([
["backbone-1", "backbone"],
["bridge-1", "bridge"],
["selected-1", "selected"],
["invalid-bridge", "bridge"],
]);
const capped = buildGraphStructureCurveCache({
graphRef: graph,
cacheKey: "test-cache",
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
maxCurves: 1,
curveStrength: 0.12,
});
assert.equal(capped.curves.length, 1);
assert.equal(capped.curves[0].edgeId, "bridge-1");
assert.equal(capped.bridgeCurveCount, 1);
assert.equal(capped.backboneCurveCount, 0);
const uncapped = buildGraphStructureCurveCache({
graphRef: graph,
cacheKey: "test-cache-all",
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
maxCurves: 10,
curveStrength: 0.12,
});
assert.deepEqual(
uncapped.curves.map((curve) => curve.edgeId).sort(),
["backbone-1", "bridge-1"],
);
});
test("resolveEdgeVisualState suppresses automatic overview backbone in clean baseline", () => {
const state = resolveEdgeVisualState(
"overview-backbone-high-priority",
"source",
"target",
"overview",
null,
"",
"",
new Set(),
new Set(),
);
assert.equal(state, "inactive");
});
test("resolveEdgeElementStyle keeps full-graph selected and path edges controlled", () => {
const selectedStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"selected",
{
edgeType: "related_to",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"selected-context-edge",
);
const pathStyle = resolveEdgeElementStyle(
GRAPH_THEME,
"inspection",
"path",
{
edgeType: "causes",
weight: 2,
properties: {},
visualPriority: 0.9,
baseSize: 0.8,
},
"source",
"target",
"full",
"path-context-edge",
);
assert.equal(selectedStyle.hidden, false);
assert.match(selectedStyle.color ?? "", /rgba\(.+,\s*0\.6\)/);
assert.equal(pathStyle.hidden, false);
assert.match(pathStyle.color ?? "", /rgba\(.+,\s*0\.76\)/);
});
test("buildGraphAnalyticsSnapshot emits a readable capped overview backbone", () => {
const semanticGroups = ["gene/protein", "disease", "drug", "pathway"];
for (let index = 0; index < 16; index += 1) {
addNode(`n${index}`, semanticGroups[index % semanticGroups.length]);
}
let edgeIndex = 0;
for (let sourceIndex = 0; sourceIndex < 16; sourceIndex += 1) {
for (let offset = 1; offset <= 3; offset += 1) {
const targetIndex = (sourceIndex + offset * 3) % 16;
if (sourceIndex === targetIndex) {
continue;
}
addEdge(`ambient-edge-${edgeIndex}`, `n${sourceIndex}`, `n${targetIndex}`, 1 + (edgeIndex % 5));
edgeIndex += 1;
}
}
const base = computeGraphAnalyticsBase(graph, {
computeCommunities: false,
computeCentrality: true,
});
const analytics = buildGraphAnalyticsSnapshot({
graphRef: graph,
interactionState: {
hoveredNodeId: null,
selectedNodeId: "",
selectedEdgeId: "",
focusedNodeId: "",
activePath: [],
activePathEdgeIds: [],
viewMode: "full",
zoomTier: "overview",
isLayoutRunning: false,
},
base,
visibleNodeIds: graph.nodes(),
});
assert.equal(analytics.overviewBackbone.ready, true);
assert.ok(analytics.overviewBackbone.edgeIds.length > 6);
assert.ok(analytics.overviewBackbone.edgeIds.length <= 128);
});
test("resolveDisplayGraph bundles parallel edges in full view", () => {
addNode("a");
addNode("b");
@@ -257,3 +1060,4 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
assert.equal(result.available, true);
assert.equal(result.reason, null);
});
+4 -3
View File
@@ -211,10 +211,10 @@ explorer-lite = [
"streamlit-agraph>=0.0.45"
]
# Everything
# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux)
all = [
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,explorer]",
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,agno]"
"semantica[dev,viz,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,explorer]",
"semantica[dev,viz,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,agno]"
]
# ---------------- ENTRYPOINTS ----------------
@@ -223,6 +223,7 @@ semantica = "semantica.cli:main"
semantica-server = "semantica.server:main"
semantica-worker = "semantica.worker:main"
semantica-explorer = "semantica.explorer:main"
semantica-mcp = "semantica.mcp_server:main"
# ---------------- TOOLING ----------------
[tool.setuptools.packages.find]
+5 -5
View File
@@ -1,9 +1,9 @@
mkdocs>=1.5.0
mkdocs-material>=9.4.0
mkdocs>=1.6.1
mkdocs-material>=9.7.6
mkdocs-minify-plugin>=0.7.0
mkdocs-mermaid2-plugin>=1.0.0
pymdown-extensions>=10.0
mkdocs-mermaid2-plugin>=1.2.3
pymdown-extensions>=10.21.2
mkdocstrings[python]>=0.24.0
mkdocs-jupyter>=0.24.0
mkdocs-jupyter>=0.26.3
+39 -53
View File
@@ -133,48 +133,6 @@ class ConflictDetector:
self.detected_conflicts: Dict[str, Conflict] = {}
def detect_conflicts(
self,
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
method: str = "entity",
property_name: Optional[str] = None,
entity_type: Optional[str] = None,
**kwargs,
) -> List[Conflict]:
"""
Detect conflicts using the specified method (convenience method).
Args:
entities: Entities to check (List of dicts or a KG dict)
method: Detection method ("entity", "value", "type", "relationship", "temporal", "logical")
property_name: Property name for "value" method
entity_type: Optional entity type filter
**kwargs: Additional arguments
Returns:
List of detected conflicts
"""
# If passed a KG dict, extract entities
if isinstance(entities, dict) and "entities" in entities:
entities = entities["entities"]
if method == "value":
if not property_name:
raise ValueError("property_name is required for value conflict detection")
return self.detect_value_conflicts(entities, property_name, entity_type)
elif method == "type":
return self.detect_type_conflicts(entities)
elif method == "relationship":
relationships = kwargs.get("relationships", [])
return self.detect_relationship_conflicts(relationships)
elif method == "temporal":
return self.detect_temporal_conflicts(entities)
elif method == "logical":
return self.detect_logical_conflicts(entities)
else:
# Default to entity-wide detection
return self.detect_entity_conflicts(entities, entity_type)
def detect_value_conflicts(
self,
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
@@ -596,11 +554,6 @@ class ConflictDetector:
tracking_id, status="failed", message=str(e)
)
raise
for field_name in fields_to_check:
conflicts = self.detect_value_conflicts(entities, field_name, entity_type)
all_conflicts.extend(conflicts)
return all_conflicts
def _calculate_conflict_confidence(
self, values: List[Any], sources: List[Dict[str, Any]]
@@ -1252,20 +1205,25 @@ class ConflictDetector:
def detect_conflicts(
self,
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
method: str = "all",
property_name: Optional[str] = None,
entity_type: Optional[str] = None,
**kwargs,
) -> List[Conflict]:
"""
Detect all conflicts for entities (general method).
This method detects all types of conflicts: value, type, relationship,
temporal, and logical conflicts.
Detect conflicts using the specified method.
Args:
entities: List of entity dictionaries or Graph dictionary (containing "entities" key)
entities: List of entity dictionaries or Graph dictionary
method: Detection method "all" (default), "value", "property", "type",
"relationship", "temporal", "logical", or "entity"
property_name: Property name required for ``method="value"`` and ``method="property"``
entity_type: Optional entity type filter
**kwargs: Extra arguments forwarded to the underlying method
(e.g. ``relationships=`` for ``method="relationship"``)
Returns:
List of all detected conflicts
List of detected conflicts
"""
# Handle graph dictionary input
if isinstance(entities, dict):
@@ -1275,6 +1233,34 @@ class ConflictDetector:
# If it's a single entity dict, wrap in list
entities = [entities]
# Dispatch to a specific sub-method when one is requested
if method == "value":
if not property_name:
raise ValueError("property_name is required for method='value'")
return self.detect_value_conflicts(entities, property_name, entity_type)
elif method == "property":
if not property_name:
raise ValueError("property_name is required for method='property'")
return self.detect_property_conflicts(entities, property_name)
elif method == "type":
return self.detect_type_conflicts(entities)
elif method == "relationship":
relationships = kwargs.get("relationships", [])
if isinstance(relationships, dict):
inner = relationships.get("relationships", relationships)
relationships = inner if isinstance(inner, list) else [inner]
if not isinstance(relationships, list):
relationships = [relationships]
return self.detect_relationship_conflicts(relationships)
elif method == "temporal":
return self.detect_temporal_conflicts(entities)
elif method == "logical":
return self.detect_logical_conflicts(entities)
elif method == "entity":
return self.detect_entity_conflicts(entities, entity_type)
elif method != "all":
raise ValueError(f"Unknown conflict detection method: {method!r}")
tracking_id = self.progress_tracker.start_tracking(
file=None,
module="conflicts",
+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."""
+109 -17
View File
@@ -100,6 +100,10 @@ class DuplicateDetector:
confidence_threshold: float = 0.6,
use_clustering: bool = True,
config: Optional[Dict[str, Any]] = None,
max_results: Optional[int] = None,
top_k_per_entity: Optional[int] = None,
min_similarity: Optional[float] = None,
sort_by: str = "confidence",
**kwargs,
):
"""
@@ -115,6 +119,16 @@ class DuplicateDetector:
(0.0 to 1.0, default: 0.6)
use_clustering: Whether to use clustering for group formation (default: True)
config: Configuration dictionary (merged with kwargs)
max_results: Hard cap on total candidates returned across all entities.
Applied after sorting. ``None`` means no limit.
top_k_per_entity: Keep at most this many candidates per entity (by the
sort field). ``None`` means no per-entity limit.
min_similarity: Additional similarity floor applied on top of
``similarity_threshold``. Candidates whose
``similarity_score`` is below this value are dropped
before ranking. ``None`` means no extra floor.
sort_by: Field used for ranking before limits are applied.
``"confidence"`` (default) or ``"similarity_score"``.
**kwargs: Additional configuration options:
- similarity: Configuration for SimilarityCalculator
"""
@@ -133,6 +147,30 @@ class DuplicateDetector:
self.confidence_threshold = confidence_threshold
self.use_clustering = use_clustering
# Result limiting / ranking options — validate at construction time
if max_results is not None and (not isinstance(max_results, int) or max_results < 0):
raise ValueError(
f"max_results must be None or a non-negative int, got {max_results!r}"
)
if top_k_per_entity is not None and (
not isinstance(top_k_per_entity, int) or top_k_per_entity < 0
):
raise ValueError(
f"top_k_per_entity must be None or a non-negative int, got {top_k_per_entity!r}"
)
if min_similarity is not None and not (0.0 <= min_similarity <= 1.0):
raise ValueError(
f"min_similarity must be None or a float in [0.0, 1.0], got {min_similarity!r}"
)
if sort_by not in ("confidence", "similarity_score"):
raise ValueError(
f"sort_by must be 'confidence' or 'similarity_score', got {sort_by!r}"
)
self.max_results = max_results
self.top_k_per_entity = top_k_per_entity
self.min_similarity = min_similarity
self.sort_by = sort_by
# Initialize progress tracker and ensure it's enabled
self.progress_tracker = get_progress_tracker()
if not self.progress_tracker.enabled:
@@ -140,7 +178,9 @@ class DuplicateDetector:
self.logger.debug(
f"Duplicate detector initialized: similarity_threshold={similarity_threshold}, "
f"confidence_threshold={confidence_threshold}"
f"confidence_threshold={confidence_threshold}, max_results={max_results}, "
f"top_k_per_entity={top_k_per_entity}, min_similarity={min_similarity}, "
f"sort_by={sort_by!r}"
)
def detect_duplicates(
@@ -153,8 +193,10 @@ class DuplicateDetector:
Detect duplicate entities from a list.
This method compares all pairs of entities and identifies duplicates based
on similarity scores and confidence thresholds. Returns candidates sorted
by confidence (highest first).
on similarity scores and confidence thresholds. Results are filtered and
ranked by ``_apply_result_limits`` using the instance ``sort_by`` field
(default: ``"confidence"``), then capped by ``top_k_per_entity`` and
``max_results``.
Args:
entities: List of entity dictionaries to check for duplicates.
@@ -163,8 +205,9 @@ class DuplicateDetector:
**options: Additional detection options passed to similarity calculator
Returns:
List of DuplicateCandidate objects, sorted by confidence (highest first).
Each candidate contains:
List of DuplicateCandidate objects sorted by the ``sort_by`` field
(highest first, default ``"confidence"``), capped by ``top_k_per_entity``
and ``max_results``. Each candidate contains:
- entity1, entity2: The duplicate entity pair
- similarity_score: Similarity score (0.0 to 1.0)
- confidence: Confidence score (0.0 to 1.0)
@@ -255,8 +298,8 @@ class DuplicateDetector:
message=f"Creating duplicate candidates... {i + 1}/{total_similarities} (remaining: {remaining})",
)
# Sort by confidence (highest first)
candidates.sort(key=lambda c: c.confidence, reverse=True)
# Sort, filter, and cap results
candidates = self._apply_result_limits(candidates)
self.logger.info(
f"Detected {len(candidates)} duplicate candidate(s) "
@@ -521,7 +564,9 @@ class DuplicateDetector:
Returns:
List of DuplicateCandidate objects representing duplicates between
new and existing entities, sorted by confidence (highest first).
new and existing entities, sorted by the ``sort_by`` field (highest
first, default ``"confidence"``), capped by ``top_k_per_entity`` and
``max_results``.
Example:
>>> new_entities = [{"id": "3", "name": "Apple Corp"}]
@@ -600,8 +645,8 @@ class DuplicateDetector:
message=f"Comparing entities... {processed}/{total_comparisons} (remaining: {remaining})",
)
# Sort by confidence (highest first)
candidates.sort(key=lambda c: c.confidence, reverse=True)
# Sort, filter, and cap results
candidates = self._apply_result_limits(candidates)
self.logger.info(
f"Incremental detection found {len(candidates)} duplicate candidate(s)"
@@ -620,6 +665,57 @@ class DuplicateDetector:
)
raise
def _apply_result_limits(
self, candidates: List[DuplicateCandidate]
) -> List[DuplicateCandidate]:
"""
Apply min_similarity filter, sort, top_k_per_entity, and max_results cap.
Order of operations:
1. Drop candidates below ``min_similarity`` (if set).
2. Sort by ``sort_by`` field descending.
3. Apply ``top_k_per_entity``: for each entity id keep only the top-k
candidates in which it appears.
4. Apply ``max_results`` global cap.
"""
# 1. min_similarity filter
if self.min_similarity is not None:
candidates = [
c for c in candidates if c.similarity_score >= self.min_similarity
]
# 2. Sort descending by the chosen field
candidates.sort(key=lambda c: getattr(c, self.sort_by), reverse=True)
# 3. top_k_per_entity — keep a candidate if *either* entity is still under
# quota; once an entity reaches k it no longer sponsors new candidates.
if self.top_k_per_entity is not None:
entity_counts: Dict[str, int] = {}
kept: List[DuplicateCandidate] = []
for c in candidates:
nid1 = self._normalize_entity_id(c.entity1)
nid2 = self._normalize_entity_id(c.entity2)
count1 = entity_counts.get(nid1, 0)
count2 = entity_counts.get(nid2, 0)
if count1 < self.top_k_per_entity or count2 < self.top_k_per_entity:
kept.append(c)
entity_counts[nid1] = count1 + 1
entity_counts[nid2] = count2 + 1
candidates = kept
# 4. max_results global cap
if self.max_results is not None:
candidates = candidates[: self.max_results]
return candidates
def _normalize_entity_id(self, entity: Any) -> str:
"""Return a stable string key for an entity, used as a dict key throughout the class."""
raw = self._get_entity_value(entity, "id")
if raw is None:
raw = id(entity)
return str(raw)
def _get_entity_value(self, entity: Any, key: str, default: Any = None) -> Any:
"""Get value from entity dictionary or object safely."""
if hasattr(entity, "__dict__"):
@@ -715,12 +811,8 @@ class DuplicateDetector:
groups = []
for candidate in candidates:
entity1_id = self._get_entity_value(candidate.entity1, "id") or id(
candidate.entity1
)
entity2_id = self._get_entity_value(candidate.entity2, "id") or id(
candidate.entity2
)
entity1_id = self._normalize_entity_id(candidate.entity1)
entity2_id = self._normalize_entity_id(candidate.entity2)
group1 = entity_to_group.get(entity1_id)
group2 = entity_to_group.get(entity2_id)
@@ -764,7 +856,7 @@ class DuplicateDetector:
# Update references
for entity in group2.entities:
entity_id = self._get_entity_value(entity, "id") or id(entity)
entity_id = self._normalize_entity_id(entity)
entity_to_group[entity_id] = group1
if group2 in groups:
+2
View File
@@ -98,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
@@ -113,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
+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,
+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"'},
)
+449 -18
View File
@@ -3,14 +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,
@@ -21,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
@@ -39,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)
@@ -144,14 +243,14 @@ class _PathAlgorithm(str, Enum):
@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),
):
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.")
@@ -163,20 +262,107 @@ async def find_path(
else path_finder.bfs_shortest_path
)
try:
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
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 '{node_id}' to '{target}'")
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,
@@ -184,23 +370,268 @@ async def find_path(
total_weight=total_weight,
directed=directed,
hop_count=hop_count,
distance_band=classify_path_distance(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),
File diff suppressed because it is too large Load Diff
+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,
)
+105 -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
@@ -70,6 +71,13 @@ class PathResponse(BaseModel):
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):
@@ -84,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):
@@ -308,3 +324,91 @@ class ProvenanceEdge(BaseModel):
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"],
)
+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)
+96 -35
View File
@@ -112,22 +112,18 @@ Example Usage:
>>> content = ingest_web("https://example.com", method="url")
"""
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from __future__ import annotations
import importlib
from typing import Any, Dict, Tuple
from .config import IngestConfig, ingest_config
from .db_ingestor import DatabaseConnector, DataExporter, DBIngestor, TableData
from .email_ingestor import AttachmentProcessor, EmailData, EmailIngestor
from .email_ingestor import EmailParser as EmailIngestorParser
from .feed_ingestor import FeedData, FeedIngestor, FeedItem, FeedMonitor, FeedParser
from .file_ingestor import (
CloudStorageIngestor,
FileIngestor,
FileObject,
FileTypeDetector,
)
from .mcp_client import MCPClient, MCPResource, MCPTool
from .mcp_ingestor import MCPData, MCPIngestor
from .methods import (
get_ingest_method,
ingest,
@@ -143,34 +139,99 @@ from .methods import (
list_available_methods,
)
from .registry import MethodRegistry, method_registry
from .repo_ingestor import (
CodeExtractor,
CodeFile,
CommitInfo,
GitAnalyzer,
RepoIngestor,
)
from .stream_ingestor import (
KafkaProcessor,
KinesisProcessor,
PulsarProcessor,
RabbitMQProcessor,
StreamIngestor,
StreamMessage,
StreamMonitor,
StreamProcessor,
)
from .web_ingestor import (
ContentExtractor,
RateLimiter,
RobotsChecker,
SitemapCrawler,
WebContent,
WebIngestor,
)
from .ontology_ingestor import OntologyData, OntologyIngestor
from .snowflake_ingestor import SnowflakeConnector, SnowflakeData, SnowflakeIngestor
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
# Web ingestion
"WebIngestor": (".web_ingestor", "WebIngestor"),
"WebContent": (".web_ingestor", "WebContent"),
"RateLimiter": (".web_ingestor", "RateLimiter"),
"RobotsChecker": (".web_ingestor", "RobotsChecker"),
"ContentExtractor": (".web_ingestor", "ContentExtractor"),
"SitemapCrawler": (".web_ingestor", "SitemapCrawler"),
# Feed ingestion
"FeedIngestor": (".feed_ingestor", "FeedIngestor"),
"FeedItem": (".feed_ingestor", "FeedItem"),
"FeedData": (".feed_ingestor", "FeedData"),
"FeedParser": (".feed_ingestor", "FeedParser"),
"FeedMonitor": (".feed_ingestor", "FeedMonitor"),
# Stream ingestion
"StreamIngestor": (".stream_ingestor", "StreamIngestor"),
"StreamMessage": (".stream_ingestor", "StreamMessage"),
"StreamProcessor": (".stream_ingestor", "StreamProcessor"),
"KafkaProcessor": (".stream_ingestor", "KafkaProcessor"),
"RabbitMQProcessor": (".stream_ingestor", "RabbitMQProcessor"),
"KinesisProcessor": (".stream_ingestor", "KinesisProcessor"),
"PulsarProcessor": (".stream_ingestor", "PulsarProcessor"),
"StreamMonitor": (".stream_ingestor", "StreamMonitor"),
# Repository ingestion
"RepoIngestor": (".repo_ingestor", "RepoIngestor"),
"CodeFile": (".repo_ingestor", "CodeFile"),
"CommitInfo": (".repo_ingestor", "CommitInfo"),
"CodeExtractor": (".repo_ingestor", "CodeExtractor"),
"GitAnalyzer": (".repo_ingestor", "GitAnalyzer"),
# Email ingestion
"EmailIngestor": (".email_ingestor", "EmailIngestor"),
"EmailData": (".email_ingestor", "EmailData"),
"AttachmentProcessor": (".email_ingestor", "AttachmentProcessor"),
"EmailIngestorParser": (".email_ingestor", "EmailParser"),
# Database ingestion
"DBIngestor": (".db_ingestor", "DBIngestor"),
"TableData": (".db_ingestor", "TableData"),
"DatabaseConnector": (".db_ingestor", "DatabaseConnector"),
"DataExporter": (".db_ingestor", "DataExporter"),
# MCP ingestion
"MCPIngestor": (".mcp_ingestor", "MCPIngestor"),
"MCPData": (".mcp_ingestor", "MCPData"),
"MCPClient": (".mcp_client", "MCPClient"),
"MCPResource": (".mcp_client", "MCPResource"),
"MCPTool": (".mcp_client", "MCPTool"),
# Ontology ingestion
"OntologyIngestor": (".ontology_ingestor", "OntologyIngestor"),
"OntologyData": (".ontology_ingestor", "OntologyData"),
# Snowflake ingestion
"SnowflakeIngestor": (".snowflake_ingestor", "SnowflakeIngestor"),
"SnowflakeData": (".snowflake_ingestor", "SnowflakeData"),
"SnowflakeConnector": (".snowflake_ingestor", "SnowflakeConnector"),
}
_OPTIONAL_DEPENDENCY_MESSAGES = {
".repo_ingestor": (
"Repository ingestion requires optional dependency 'GitPython'. "
"Install it before importing RepoIngestor or using ingest_repository()."
),
".web_ingestor": (
"Web ingestion requires optional dependency 'beautifulsoup4'. "
"Install it before importing WebIngestor or using ingest_web()."
),
".feed_ingestor": (
"Feed ingestion requires optional dependency 'beautifulsoup4'. "
"Install it before importing FeedIngestor or using ingest_feed()."
),
".email_ingestor": (
"Email ingestion requires optional dependency 'beautifulsoup4'. "
"Install it before importing EmailIngestor or using ingest_email()."
),
}
def __getattr__(name: str) -> Any:
"""Load optional ingestion backends only when callers request them."""
if name not in _LAZY_EXPORTS:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = _LAZY_EXPORTS[name]
try:
module = importlib.import_module(module_name, __name__)
except ModuleNotFoundError as exc:
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
missing_name = getattr(exc, "name", None)
if message and missing_name in {"git", "bs4"}:
raise ImportError(message) from exc
raise
value = getattr(module, attr_name)
globals()[name] = value
return value
__all__ = [
# File ingestion
+67 -8
View File
@@ -139,26 +139,32 @@ Example Usage:
>>> content = ingest_web("https://example.com", method="url")
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
from ..utils.exceptions import ConfigurationError, ProcessingError
from ..utils.logging import get_logger
from .config import ingest_config
from .db_ingestor import DBIngestor, TableData
from .email_ingestor import EmailData, EmailIngestor
from .feed_ingestor import FeedData, FeedIngestor
from .file_ingestor import FileIngestor, FileObject
from .mcp_ingestor import MCPData, MCPIngestor
from .ontology_ingestor import OntologyData, OntologyIngestor
from .registry import method_registry
from .repo_ingestor import RepoIngestor
from .stream_ingestor import StreamIngestor, StreamProcessor
from .web_ingestor import WebContent, WebIngestor
logger = get_logger("ingest_methods")
def _missing_optional_dependency(feature: str, package: str) -> ConfigurationError:
return ConfigurationError(
f"{feature} requires optional dependency '{package}'. "
f"Install it before using this ingestion backend."
)
def _is_missing_dependency(exc: ModuleNotFoundError, *dependency_names: str) -> bool:
missing_name = getattr(exc, "name", None)
return missing_name in dependency_names
def ingest_file(
source: Union[str, Path, List[Union[str, Path]]], method: str = "file", **kwargs
) -> Union[FileObject, List[FileObject], Dict[str, Any]]:
@@ -259,6 +265,16 @@ def ingest_web(
)
try:
try:
from .web_ingestor import WebIngestor
except ModuleNotFoundError as exc:
if _is_missing_dependency(exc, "bs4"):
raise _missing_optional_dependency(
"Web ingestion",
"beautifulsoup4",
) from exc
raise
# Get config
config = ingest_config.get_method_config("web")
config.update(kwargs)
@@ -278,6 +294,8 @@ def ingest_web(
# Default: try as URL
return ingestor.ingest_url(source, **kwargs)
except ConfigurationError:
raise
except Exception as e:
logger.error(f"Failed to ingest web: {e}")
raise
@@ -318,6 +336,16 @@ def ingest_feed(
)
try:
try:
from .feed_ingestor import FeedIngestor
except ModuleNotFoundError as exc:
if _is_missing_dependency(exc, "bs4"):
raise _missing_optional_dependency(
"Feed ingestion",
"beautifulsoup4",
) from exc
raise
# Get config
config = ingest_config.get_method_config("feed")
config.update(kwargs)
@@ -332,6 +360,8 @@ def ingest_feed(
else:
return ingestor.ingest_feed(source, **kwargs)
except ConfigurationError:
raise
except Exception as e:
logger.error(f"Failed to ingest feed: {e}")
raise
@@ -375,6 +405,8 @@ def ingest_stream(
)
try:
from .stream_ingestor import StreamIngestor
# Get config
config = ingest_config.get_method_config("stream")
config.update(kwargs)
@@ -447,6 +479,13 @@ def ingest_repository(
)
try:
try:
from .repo_ingestor import RepoIngestor
except ModuleNotFoundError as exc:
if _is_missing_dependency(exc, "git"):
raise _missing_optional_dependency("Repository ingestion", "GitPython") from exc
raise
# Get config
config = ingest_config.get_method_config("repo")
config.update(kwargs)
@@ -464,6 +503,8 @@ def ingest_repository(
# Default: ingest repository
return ingestor.ingest_repository(source, **kwargs)
except ConfigurationError:
raise
except Exception as e:
logger.error(f"Failed to ingest repository: {e}")
raise
@@ -505,6 +546,16 @@ def ingest_email(
)
try:
try:
from .email_ingestor import EmailIngestor
except ModuleNotFoundError as exc:
if _is_missing_dependency(exc, "bs4"):
raise _missing_optional_dependency(
"Email ingestion",
"beautifulsoup4",
) from exc
raise
# Get config
config = ingest_config.get_method_config("email")
config.update(kwargs)
@@ -533,6 +584,8 @@ def ingest_email(
else:
raise ProcessingError(f"Unknown email method: {method}")
except ConfigurationError:
raise
except Exception as e:
logger.error(f"Failed to ingest email: {e}")
raise
@@ -572,6 +625,8 @@ def ingest_ontology(
)
try:
from .ontology_ingestor import OntologyIngestor
# Get config
config = ingest_config.get_method_config("ontology")
config.update(kwargs)
@@ -636,6 +691,8 @@ def ingest_database(
)
try:
from .db_ingestor import DBIngestor
# Get config
config = ingest_config.get_method_config("db")
config.update(kwargs)
@@ -728,6 +785,8 @@ def ingest_mcp(
)
try:
from .mcp_ingestor import MCPIngestor
# Get config
config = ingest_config.get_method_config("mcp")
config.update(kwargs)
@@ -11,6 +11,15 @@ Usage
Configure in your tool's MCP settings:
Claude Desktop / Windsurf / Cline / Continue / VS Code:
{
"mcpServers": {
"semantica": {
"command": "semantica-mcp"
}
}
}
Or using python -m:
{
"mcpServers": {
"semantica": {
@@ -20,7 +29,9 @@ Configure in your tool's MCP settings:
}
}
Run directly:
Run directly for testing:
semantica-mcp
# or
python -m semantica.mcp_server
Environment variables:
@@ -71,9 +82,7 @@ def _tool_extract_entities(args: dict) -> dict:
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import NamedEntityRecognizer
from semantica.semantic_extract.cache import _result_cache
_result_cache.clear()
entities = NamedEntityRecognizer().extract(text)
entities = NamedEntityRecognizer().extract_entities(text)
return {
"entities": [
{"label": getattr(e, "label", str(e)),
@@ -91,10 +100,8 @@ def _tool_extract_relations(args: dict) -> dict:
if not text:
return {"error": "text is required"}
from semantica.semantic_extract import RelationExtractor, TripletExtractor
from semantica.semantic_extract.cache import _result_cache
_result_cache.clear()
relations = RelationExtractor().extract(text)
triplets = TripletExtractor().extract(text)
relations = RelationExtractor().extract_relations(text)
triplets = TripletExtractor().extract_triplets(text)
return {
"relations": [
{"source": getattr(r, "source", None),
@@ -600,7 +607,3 @@ def _run_stdio():
def main():
_run_stdio()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""
Entry point for python -m semantica.mcp_server
"""
from semantica.mcp_server import main
if __name__ == "__main__":
main()
+90 -66
View File
@@ -47,73 +47,97 @@ Author: Semantica Contributors
License: MIT
"""
from typing import Any, Dict, List, Optional, Union
from __future__ import annotations
from .config import Config, config
from .coreference_resolver import (
CoreferenceChain,
CoreferenceChainBuilder,
CoreferenceResolver,
EntityCoreferenceDetector,
Mention,
PronounResolver,
)
from .event_detector import (
Event,
EventClassifier,
EventDetector,
EventRelationshipExtractor,
TemporalEventProcessor,
)
from .extraction_validator import ExtractionValidator, ValidationResult
from .llm_extraction import LLMEnhancer, LLMExtraction, LLMResponse
from .methods import get_entity_method, get_relation_method, get_triplet_method
from .named_entity_recognizer import (
CustomEntityDetector,
EntityClassifier,
EntityConfidenceScorer,
NamedEntityRecognizer,
)
from .ner_extractor import Entity, NERExtractor
from .providers import (
AnthropicProvider,
BaseProvider,
GeminiProvider,
GroqProvider,
HuggingFaceLLMProvider,
HuggingFaceModelLoader,
OllamaProvider,
OpenAIProvider,
create_provider,
)
from .registry import (
MethodRegistry,
ProviderRegistry,
method_registry,
provider_registry,
)
from .relation_extractor import Relation, RelationExtractor
from .semantic_analyzer import (
RoleLabeler,
SemanticAnalyzer,
SemanticCluster,
SemanticClusterer,
SemanticRole,
SimilarityAnalyzer,
)
from .semantic_network_extractor import (
SemanticEdge,
SemanticNetwork,
SemanticNetworkExtractor,
SemanticNode,
)
from .triplet_extractor import (
RDFSerializer,
Triplet,
TripletExtractor,
TripletQualityChecker,
TripletValidator,
)
import importlib
from typing import Any, Dict, Tuple
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
# Named Entity Recognition
"NamedEntityRecognizer": (".named_entity_recognizer", "NamedEntityRecognizer"),
"EntityClassifier": (".named_entity_recognizer", "EntityClassifier"),
"EntityConfidenceScorer": (".named_entity_recognizer", "EntityConfidenceScorer"),
"CustomEntityDetector": (".named_entity_recognizer", "CustomEntityDetector"),
"NERExtractor": (".ner_extractor", "NERExtractor"),
"Entity": (".types", "Entity"),
# Relation Extraction
"RelationExtractor": (".relation_extractor", "RelationExtractor"),
"Relation": (".types", "Relation"),
# Event Detection
"EventDetector": (".event_detector", "EventDetector"),
"Event": (".event_detector", "Event"),
"EventClassifier": (".event_detector", "EventClassifier"),
"TemporalEventProcessor": (".event_detector", "TemporalEventProcessor"),
"EventRelationshipExtractor": (".event_detector", "EventRelationshipExtractor"),
# Coreference Resolution
"CoreferenceResolver": (".coreference_resolver", "CoreferenceResolver"),
"Mention": (".coreference_resolver", "Mention"),
"CoreferenceChain": (".coreference_resolver", "CoreferenceChain"),
"PronounResolver": (".coreference_resolver", "PronounResolver"),
"EntityCoreferenceDetector": (".coreference_resolver", "EntityCoreferenceDetector"),
"CoreferenceChainBuilder": (".coreference_resolver", "CoreferenceChainBuilder"),
# Triplet Extraction
"TripletExtractor": (".triplet_extractor", "TripletExtractor"),
"TripleExtractor": (".triplet_extractor", "TripletExtractor"),
"Triplet": (".types", "Triplet"),
"TripletValidator": (".triplet_extractor", "TripletValidator"),
"RDFSerializer": (".triplet_extractor", "RDFSerializer"),
"TripletQualityChecker": (".triplet_extractor", "TripletQualityChecker"),
# Semantic Analysis
"SemanticAnalyzer": (".semantic_analyzer", "SemanticAnalyzer"),
"SemanticRole": (".semantic_analyzer", "SemanticRole"),
"SemanticCluster": (".semantic_analyzer", "SemanticCluster"),
"SimilarityAnalyzer": (".semantic_analyzer", "SimilarityAnalyzer"),
"RoleLabeler": (".semantic_analyzer", "RoleLabeler"),
"SemanticClusterer": (".semantic_analyzer", "SemanticClusterer"),
# Semantic Network
"SemanticNetworkExtractor": (".semantic_network_extractor", "SemanticNetworkExtractor"),
"SemanticNode": (".semantic_network_extractor", "SemanticNode"),
"SemanticEdge": (".semantic_network_extractor", "SemanticEdge"),
"SemanticNetwork": (".semantic_network_extractor", "SemanticNetwork"),
# LLM Enhancement
"LLMExtraction": (".llm_extraction", "LLMExtraction"),
"LLMEnhancer": (".llm_extraction", "LLMEnhancer"),
"LLMResponse": (".llm_extraction", "LLMResponse"),
# Validation
"ExtractionValidator": (".extraction_validator", "ExtractionValidator"),
"ValidationResult": (".extraction_validator", "ValidationResult"),
# Providers
"BaseProvider": (".providers", "BaseProvider"),
"OpenAIProvider": (".providers", "OpenAIProvider"),
"GeminiProvider": (".providers", "GeminiProvider"),
"GroqProvider": (".providers", "GroqProvider"),
"AnthropicProvider": (".providers", "AnthropicProvider"),
"OllamaProvider": (".providers", "OllamaProvider"),
"HuggingFaceLLMProvider": (".providers", "HuggingFaceLLMProvider"),
"HuggingFaceModelLoader": (".providers", "HuggingFaceModelLoader"),
"create_provider": (".providers", "create_provider"),
# Registry
"ProviderRegistry": (".registry", "ProviderRegistry"),
"MethodRegistry": (".registry", "MethodRegistry"),
"provider_registry": (".registry", "provider_registry"),
"method_registry": (".registry", "method_registry"),
# Config
"Config": (".config", "Config"),
"config": (".config", "config"),
# Methods
"get_entity_method": (".methods", "get_entity_method"),
"get_relation_method": (".methods", "get_relation_method"),
"get_triplet_method": (".methods", "get_triplet_method"),
}
def __getattr__(name: str) -> Any:
"""Lazily load semantic extraction exports to avoid import cycles and optional deps."""
if name not in _LAZY_EXPORTS:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr_name = _LAZY_EXPORTS[name]
module = importlib.import_module(module_name, __name__)
value = getattr(module, attr_name)
globals()[name] = value
return value
__all__ = [
# Named Entity Recognition
+1 -3
View File
@@ -113,13 +113,11 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
from .ner_extractor import Entity
from .providers import HuggingFaceModelLoader, create_provider
from .registry import method_registry
from .relation_extractor import Relation
from .triplet_extractor import Triplet
from .cache import ExtractionCache
from .config import config
from .types import Entity, Relation, Triplet
try:
from .schemas import (
+1 -13
View File
@@ -70,29 +70,17 @@ Author: Semantica Contributors
License: MIT
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.exceptions import ProcessingError
from ..utils.helpers import safe_import
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .types import Entity
spacy, SPACY_AVAILABLE = safe_import("spacy")
@dataclass
class Entity:
"""Entity representation."""
text: str
label: str
start_char: int
end_char: int
confidence: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
class NERExtractor:
"""Named Entity Recognition extractor."""
@@ -70,25 +70,12 @@ License: MIT
"""
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .ner_extractor import Entity
@dataclass
class Relation:
"""Relation representation."""
subject: Entity
predicate: str
object: Entity
confidence: float = 1.0
context: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
from .types import Entity, Relation
class RelationExtractor:
@@ -70,34 +70,13 @@ Author: Semantica Contributors
License: MIT
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from urllib.parse import quote
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .ner_extractor import Entity
from .relation_extractor import Relation
@dataclass
class Triplet:
"""RDF triplet representation."""
subject: str
predicate: str
object: str
confidence: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
def get(self, key: str, default: Any = None) -> Any:
"""Get attribute value like a dictionary."""
return getattr(self, key, default)
def __getitem__(self, key: str) -> Any:
"""Get item like a dictionary."""
return getattr(self, key)
from .types import Entity, Relation, Triplet
class TripletExtractor:
+52
View File
@@ -0,0 +1,52 @@
"""
Shared semantic extraction data types.
These lightweight dataclasses live outside the extractor implementations so
method dispatchers and extractors can share result models without import cycles.
"""
from dataclasses import dataclass, field
from typing import Any, Dict
@dataclass
class Entity:
"""Entity representation."""
text: str
label: str
start_char: int
end_char: int
confidence: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Relation:
"""Relation representation."""
subject: Entity
predicate: str
object: Entity
confidence: float = 1.0
context: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Triplet:
"""RDF triplet representation."""
subject: str
predicate: str
object: str
confidence: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
def get(self, key: str, default: Any = None) -> Any:
"""Get attribute value like a dictionary."""
return getattr(self, key, default)
def __getitem__(self, key: str) -> Any:
"""Get item like a dictionary."""
return getattr(self, key)
+22 -8
View File
@@ -152,10 +152,11 @@ if EXPLORER_AVAILABLE:
enrich,
export_import,
graph,
ontology,
temporal,
vocabulary,
provenance,
sparql
provenance,
sparql
)
app.include_router(analytics.router)
@@ -164,12 +165,13 @@ if EXPLORER_AVAILABLE:
app.include_router(enrich.router)
app.include_router(export_import.router)
app.include_router(graph.router)
app.include_router(ontology.router)
app.include_router(temporal.router)
app.include_router(vocabulary.router)
app.include_router(provenance.router)
app.include_router(sparql.router)
app.include_router(provenance.router)
app.include_router(sparql.router)
logging.info("Explorer, Vocabulary, SPARQL, and Provenance API routes successfully mounted.")
logging.info("Explorer, Vocabulary, SPARQL, Provenance, and Ontology API routes successfully mounted.")
except Exception as exc:
logging.error(f"Failed to mount explorer routes: {exc}")
else:
@@ -188,10 +190,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 +214,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()
+7 -7
View File
@@ -253,21 +253,21 @@ class ConsoleProgressDisplay(ProgressDisplay):
# If we have pipeline items, show all of them
if pipeline_items:
# Clear and show all pipeline items
sys.stdout.write("\r" + " " * 150 + "\r")
self._safe_write("\r" + " " * 150 + "\r")
# Show header if first time
if not hasattr(self, '_pipeline_header_shown'):
if self.use_emoji:
sys.stdout.write("🧠 Semantica - 📊 Current Progress\n")
self._safe_write("🧠 Semantica - 📊 Current Progress\n")
else:
sys.stdout.write("Semantica - Current Progress\n")
sys.stdout.write("=" * 150 + "\n")
self._safe_write("Semantica - Current Progress\n")
self._safe_write("=" * 150 + "\n")
self._pipeline_header_shown = True
# Display all pipeline items
for pipeline_item in pipeline_items:
self._display_item_line(pipeline_item)
sys.stdout.write("\n")
self._safe_write("\n")
sys.stdout.flush()
else:
+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")]
+403 -1
View File
@@ -1,13 +1,18 @@
import sys
import unittest
from typing import Dict, Any, List
from semantica.deduplication.similarity_calculator import SimilarityCalculator
from semantica.deduplication.duplicate_detector import DuplicateDetector
from semantica.deduplication.duplicate_detector import (
DuplicateCandidate,
DuplicateDetector,
)
from semantica.deduplication.entity_merger import EntityMerger
from semantica.deduplication.merge_strategy import MergeStrategy
from semantica.deduplication.cluster_builder import ClusterBuilder
from semantica.deduplication.registry import MethodRegistry
from semantica.deduplication.config import DeduplicationConfig
from semantica.deduplication.methods import get_deduplication_method
from semantica.utils.progress_tracker import ConsoleProgressDisplay
class TestDeduplication(unittest.TestCase):
@@ -203,5 +208,402 @@ class TestDeduplication(unittest.TestCase):
self.assertIsNone(invalid)
class TestProgressTrackerEncoding(unittest.TestCase):
"""Regression tests for issue #531 — Unicode crash on cp1252 Windows consoles."""
def _make_cp1252_stdout(self):
"""Return a stdout-like object that raises UnicodeEncodeError for non-cp1252 chars."""
class CP1252Writer:
encoding = "cp1252"
def write(self, text):
text.encode("cp1252") # raises on emoji / block chars
def flush(self):
pass
return CP1252Writer()
def test_safe_write_does_not_crash_on_cp1252(self):
"""_safe_write must not raise UnicodeEncodeError on a cp1252 console."""
display = ConsoleProgressDisplay()
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
try:
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
except UnicodeEncodeError:
self.fail("_safe_write raised UnicodeEncodeError on cp1252 stdout")
finally:
sys.stdout = orig
def test_update_pipeline_header_does_not_crash_on_cp1252(self):
"""update() pipeline header write must not crash on a cp1252 console (issue #531)."""
from semantica.utils.progress_tracker import ProgressItem
display = ConsoleProgressDisplay()
display.use_emoji = True # force emoji path to exercise the fixed branch
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
try:
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
display._safe_write("=" * 150 + "\n")
except UnicodeEncodeError:
self.fail("Pipeline header write raised UnicodeEncodeError on cp1252 stdout")
finally:
sys.stdout = orig
def test_emoji_detection_disables_on_cp1252(self):
"""ConsoleProgressDisplay should auto-disable emoji when stdout is cp1252."""
orig = sys.stdout
sys.stdout = self._make_cp1252_stdout()
try:
display = ConsoleProgressDisplay()
self.assertFalse(display.use_emoji, "use_emoji should be False on cp1252 stdout")
finally:
sys.stdout = orig
class TestResultLimiting(unittest.TestCase):
"""Tests for issue #534 — max_results, top_k_per_entity, min_similarity, sort_by."""
def setUp(self):
# Six entities: three near-duplicate Apple variants + two Microsoft variants + one Google.
# Lower thresholds so all intra-brand pairs clear the bar.
self.entities = [
{"id": "a1", "name": "Apple Inc.", "type": "Company",
"properties": {"industry": "Technology"}},
{"id": "a2", "name": "Apple", "type": "Company",
"properties": {"industry": "Tech"}},
{"id": "a3", "name": "Apple Corp", "type": "Company",
"properties": {"industry": "Technology"}},
{"id": "b1", "name": "Microsoft Corporation", "type": "Company",
"properties": {"industry": "Software"}},
{"id": "b2", "name": "Microsoft Corp", "type": "Company",
"properties": {"industry": "Software"}},
{"id": "c1", "name": "Google LLC", "type": "Company",
"properties": {"industry": "Internet"}},
]
self.threshold = 0.3
def _base_detector(self, **kwargs):
return DuplicateDetector(
similarity_threshold=self.threshold,
confidence_threshold=self.threshold,
**kwargs,
)
# ------------------------------------------------------------------
# max_results
# ------------------------------------------------------------------
def test_max_results_caps_output(self):
detector = self._base_detector(max_results=1)
results = detector.detect_duplicates(self.entities)
self.assertLessEqual(len(results), 1)
def test_max_results_two(self):
detector = self._base_detector(max_results=2)
results = detector.detect_duplicates(self.entities)
self.assertLessEqual(len(results), 2)
def test_max_results_none_no_cap(self):
uncapped = self._base_detector()
large_cap = self._base_detector(max_results=999)
self.assertEqual(
len(uncapped.detect_duplicates(self.entities)),
len(large_cap.detect_duplicates(self.entities)),
)
def test_max_results_zero_returns_empty(self):
detector = self._base_detector(max_results=0)
self.assertEqual(detector.detect_duplicates(self.entities), [])
def test_max_results_returns_highest_confidence_first(self):
"""When capped, the kept candidates must be the highest-confidence ones."""
n = 2
all_results = self._base_detector().detect_duplicates(self.entities)
capped = self._base_detector(max_results=n).detect_duplicates(self.entities)
if len(all_results) >= n:
expected_ids = {
(c.entity1["id"], c.entity2["id"]) for c in all_results[:n]
}
actual_ids = {
(c.entity1["id"], c.entity2["id"]) for c in capped
}
self.assertEqual(expected_ids, actual_ids)
def test_max_results_empty_input(self):
detector = self._base_detector(max_results=5)
self.assertEqual(detector.detect_duplicates([]), [])
# ------------------------------------------------------------------
# top_k_per_entity
# ------------------------------------------------------------------
def test_top_k_per_entity_k1(self):
# OR semantics: keep if EITHER entity is under quota.
# A popular entity can appear > k times (each new partner brings it back).
# Invariant: no (entity1, entity2) pair is returned more than once.
k = 1
results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
self.assertEqual(len(pairs), len(set(pairs)), "No duplicate pairs should appear")
# OR gives >= results than AND — at least 1 result when matches exist
and_results = self._base_detector().detect_duplicates(self.entities)
if and_results:
self.assertGreater(len(results), 0)
def test_top_k_per_entity_k2(self):
# Same OR semantics: no pair appears twice; result is bounded below by k=1 count
k = 2
results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
self.assertEqual(len(pairs), len(set(pairs)), "No duplicate pairs should appear")
k1_results = self._base_detector(top_k_per_entity=1).detect_duplicates(self.entities)
self.assertGreaterEqual(len(results), len(k1_results))
def test_top_k_per_entity_large_k_same_as_none(self):
uncapped = self._base_detector().detect_duplicates(self.entities)
large_k = self._base_detector(top_k_per_entity=999).detect_duplicates(self.entities)
self.assertEqual(len(uncapped), len(large_k))
def test_top_k_per_entity_empty_input(self):
detector = self._base_detector(top_k_per_entity=2)
self.assertEqual(detector.detect_duplicates([]), [])
# ------------------------------------------------------------------
# min_similarity
# ------------------------------------------------------------------
def test_min_similarity_all_results_above_floor(self):
floor = 0.6
results = self._base_detector(min_similarity=floor).detect_duplicates(self.entities)
for c in results:
self.assertGreaterEqual(
c.similarity_score, floor,
f"Candidate score {c.similarity_score} is below min_similarity={floor}",
)
def test_min_similarity_very_high_returns_only_exact(self):
results = self._base_detector(min_similarity=1.0).detect_duplicates(self.entities)
for c in results:
self.assertEqual(c.similarity_score, 1.0)
def test_min_similarity_zero_does_not_over_filter(self):
no_floor = self._base_detector().detect_duplicates(self.entities)
zero_floor = self._base_detector(min_similarity=0.0).detect_duplicates(self.entities)
self.assertEqual(len(no_floor), len(zero_floor))
def test_min_similarity_stricter_than_threshold_reduces_results(self):
"""A min_similarity above similarity_threshold must not increase the result count."""
base = self._base_detector().detect_duplicates(self.entities)
stricter = self._base_detector(min_similarity=0.8).detect_duplicates(self.entities)
self.assertLessEqual(len(stricter), len(base))
def test_min_similarity_empty_input(self):
detector = self._base_detector(min_similarity=0.5)
self.assertEqual(detector.detect_duplicates([]), [])
# ------------------------------------------------------------------
# sort_by
# ------------------------------------------------------------------
def test_sort_by_confidence_descending(self):
results = self._base_detector(sort_by="confidence").detect_duplicates(self.entities)
scores = [c.confidence for c in results]
self.assertEqual(scores, sorted(scores, reverse=True))
def test_sort_by_similarity_score_descending(self):
results = self._base_detector(sort_by="similarity_score").detect_duplicates(self.entities)
scores = [c.similarity_score for c in results]
self.assertEqual(scores, sorted(scores, reverse=True))
def test_sort_by_default_is_confidence(self):
default = self._base_detector().detect_duplicates(self.entities)
explicit = self._base_detector(sort_by="confidence").detect_duplicates(self.entities)
self.assertEqual(
[(c.entity1["id"], c.entity2["id"]) for c in default],
[(c.entity1["id"], c.entity2["id"]) for c in explicit],
)
def test_sort_by_invalid_raises_at_construction(self):
with self.assertRaises(ValueError):
self._base_detector(sort_by="bogus_field")
def test_sort_by_invalid_message_contains_field_name(self):
with self.assertRaises(ValueError, msg="bogus_field") as ctx:
self._base_detector(sort_by="bogus_field")
self.assertIn("bogus_field", str(ctx.exception))
# ------------------------------------------------------------------
# Input validation (bug_002 / bug_003)
# ------------------------------------------------------------------
def test_max_results_negative_raises(self):
with self.assertRaises(ValueError):
self._base_detector(max_results=-1)
def test_max_results_float_raises(self):
with self.assertRaises(ValueError):
self._base_detector(max_results=1.5)
def test_top_k_per_entity_negative_raises(self):
with self.assertRaises(ValueError):
self._base_detector(top_k_per_entity=-1)
def test_top_k_per_entity_float_raises(self):
with self.assertRaises(ValueError):
self._base_detector(top_k_per_entity=2.5)
def test_min_similarity_above_1_raises(self):
with self.assertRaises(ValueError):
self._base_detector(min_similarity=1.1)
def test_min_similarity_below_0_raises(self):
with self.assertRaises(ValueError):
self._base_detector(min_similarity=-0.1)
def test_max_results_zero_is_valid(self):
# 0 is a non-negative int — must not raise
detector = self._base_detector(max_results=0)
self.assertEqual(detector.detect_duplicates(self.entities), [])
def test_top_k_per_entity_zero_is_valid(self):
detector = self._base_detector(top_k_per_entity=0)
self.assertEqual(detector.detect_duplicates(self.entities), [])
def test_min_similarity_boundary_0_valid(self):
self._base_detector(min_similarity=0.0) # must not raise
def test_min_similarity_boundary_1_valid(self):
self._base_detector(min_similarity=1.0) # must not raise
# ------------------------------------------------------------------
# top_k_per_entity OR semantics (bug_001)
# ------------------------------------------------------------------
def test_top_k_or_semantics_keeps_candidate_if_either_under_quota(self):
"""A high-ranked candidate must survive even if one of its entities hit k,
as long as the other entity is still under quota."""
# With k=1 and OR semantics, every entity can appear in AT LEAST one
# candidate. Verify that more candidates survive than would under AND.
k = 1
or_results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
# Each entity should appear at least once — no entity completely starved
seen_ids: set = set()
for c in or_results:
seen_ids.add(c.entity1["id"])
seen_ids.add(c.entity2["id"])
# Entities that have at least one match above threshold must appear
all_ids_in_any_pair: set = set()
uncapped = self._base_detector().detect_duplicates(self.entities)
for c in uncapped:
all_ids_in_any_pair.add(c.entity1["id"])
all_ids_in_any_pair.add(c.entity2["id"])
self.assertEqual(seen_ids, all_ids_in_any_pair)
def test_group_merge_updates_normalized_entity_keys_for_int_ids(self):
"""Merged groups must update the normalized string lookup keys.
Regression for stale raw int keys: after a bridge candidate merges two
groups, later candidates involving the moved int-ID entities must still
attach to the returned group rather than an orphaned removed group.
"""
entities = [
{"id": 1, "name": "Alpha"},
{"id": 2, "name": "Alpha duplicate"},
{"id": 3, "name": "Alpha bridge"},
{"id": 4, "name": "Alpha merged"},
{"id": 5, "name": "Alpha later"},
]
candidates = [
DuplicateCandidate(entities[0], entities[1], 0.95, 0.95),
DuplicateCandidate(entities[2], entities[3], 0.94, 0.94),
DuplicateCandidate(entities[1], entities[2], 0.93, 0.93),
DuplicateCandidate(entities[2], entities[4], 0.92, 0.92),
]
groups = self._base_detector()._build_duplicate_groups(candidates)
self.assertEqual(len(groups), 1)
self.assertEqual(
{entity["id"] for entity in groups[0].entities},
{1, 2, 3, 4, 5},
)
# ------------------------------------------------------------------
# Combined options
# ------------------------------------------------------------------
def test_max_results_and_sort_by_similarity(self):
n = 2
results = self._base_detector(max_results=n, sort_by="similarity_score").detect_duplicates(self.entities)
self.assertLessEqual(len(results), n)
if len(results) == 2:
self.assertGreaterEqual(results[0].similarity_score, results[1].similarity_score)
def test_min_similarity_and_top_k_combined(self):
floor, k = 0.5, 1
results = self._base_detector(min_similarity=floor, top_k_per_entity=k).detect_duplicates(self.entities)
# min_similarity floor still applies
for c in results:
self.assertGreaterEqual(c.similarity_score, floor)
# OR semantics: no pair duplicated
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
self.assertEqual(len(pairs), len(set(pairs)))
def test_all_four_options_combined(self):
results = self._base_detector(
max_results=3,
top_k_per_entity=1,
min_similarity=0.3,
sort_by="similarity_score",
).detect_duplicates(self.entities)
self.assertLessEqual(len(results), 3)
scores = [c.similarity_score for c in results]
self.assertEqual(scores, sorted(scores, reverse=True))
for c in results:
self.assertGreaterEqual(c.similarity_score, 0.3)
def test_max_results_applied_after_top_k(self):
"""max_results must slice the already-top-k-filtered list, not pre-empt it."""
top_k_only = self._base_detector(top_k_per_entity=1).detect_duplicates(self.entities)
both = self._base_detector(top_k_per_entity=1, max_results=1).detect_duplicates(self.entities)
self.assertLessEqual(len(both), min(1, len(top_k_only)))
# ------------------------------------------------------------------
# incremental_detect
# ------------------------------------------------------------------
def test_incremental_detect_max_results(self):
new_e, existing = self.entities[:3], self.entities[3:]
results = self._base_detector(max_results=1).incremental_detect(new_e, existing)
self.assertLessEqual(len(results), 1)
def test_incremental_detect_min_similarity(self):
new_e, existing = self.entities[:3], self.entities[3:]
results = self._base_detector(min_similarity=0.99).incremental_detect(new_e, existing)
for c in results:
self.assertGreaterEqual(c.similarity_score, 0.99)
def test_incremental_detect_sort_by_similarity(self):
new_e, existing = self.entities[:3], self.entities[3:]
results = self._base_detector(sort_by="similarity_score").incremental_detect(new_e, existing)
scores = [c.similarity_score for c in results]
self.assertEqual(scores, sorted(scores, reverse=True))
def test_incremental_detect_top_k_per_entity(self):
new_e, existing = self.entities[:3], self.entities[3:]
k = 1
results = self._base_detector(top_k_per_entity=k).incremental_detect(new_e, existing)
# OR semantics: no pair appears twice
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
self.assertEqual(len(pairs), len(set(pairs)))
def test_incremental_detect_empty_new_entities(self):
detector = self._base_detector(max_results=5)
self.assertEqual(detector.incremental_detect([], self.entities), [])
def test_incremental_detect_empty_existing_entities(self):
detector = self._base_detector(max_results=5)
self.assertEqual(detector.incremental_detect(self.entities, []), [])
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -733,7 +733,10 @@ def _make_path_session() -> GraphSession:
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)
@@ -742,6 +745,7 @@ def _make_path_session() -> GraphSession:
# 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
@@ -792,6 +796,22 @@ class TestBidirectionalPathRoute:
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")
@@ -865,6 +885,101 @@ class TestBidirectionalPathRoute:
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."""
+263
View File
@@ -0,0 +1,263 @@
"""Tests for Ontology Hub subissue 3 APIs."""
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
def _build_ontology_graph() -> ContextGraph:
graph = ContextGraph(advanced_analytics=False)
onto_a = "http://example.org/onto-a"
onto_b = "http://example.org/onto-b"
person_a = "http://example.org/onto-a#Person"
person_b = "http://example.org/onto-b#PersonRecord"
name_a = "http://example.org/onto-a#name"
graph.add_node(
onto_a,
node_type="owl:Ontology",
content="Ontology A",
**{"rdfs:label": "Ontology A", "rdfs:comment": "Primary ontology", "version": "1.0.0"},
)
graph.add_node(
onto_b,
node_type="owl:Ontology",
content="Ontology B",
**{"rdfs:label": "Ontology B", "rdfs:comment": "Partner ontology", "version": "1.0.0"},
)
graph.add_node(
person_a,
node_type="owl:Class",
content="Person",
scheme_uri=onto_a,
**{"rdfs:label": "Person", "rdfs:comment": "A person", "skos:definition": "Human actor"},
)
graph.add_node(
name_a,
node_type="owl:DatatypeProperty",
content="name",
scheme_uri=onto_a,
**{"rdfs:label": "name", "rdfs:comment": "Display name"},
)
graph.add_node(
person_b,
node_type="owl:Class",
content="Person Record",
scheme_uri=onto_b,
**{"rdfs:label": "Person Record", "rdfs:comment": "A person profile"},
)
graph.add_edge(name_a, person_a, edge_type="rdfs:domain")
return graph
@pytest.fixture()
def client():
app = create_app(session=GraphSession(_build_ontology_graph()))
with TestClient(app) as test_client:
yield test_client
def test_alignment_round_trip(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.91,
"provenance": "Reviewed from source mapping table",
"source": "test",
"reviewer": "qa",
}
created = client.post("/api/ontology/alignments", json=payload)
assert created.status_code == 200
alignment = created.json()
assert alignment["confidence"] == 0.91
assert alignment["provenance"] == "Reviewed from source mapping table"
listed = client.get("/api/ontology/alignments")
assert listed.status_code == 200
assert [item["id"] for item in listed.json()] == [alignment["id"]]
removed = client.delete(f"/api/ontology/alignments?id={alignment['id']}")
assert removed.status_code == 200
assert client.get("/api/ontology/alignments").json() == []
def test_alignment_suggestions_are_ranked(client):
response = client.post(
"/api/ontology/suggest-alignments",
json={
"source_ontology_uri": "http://example.org/onto-a",
"target_ontology_uri": "http://example.org/onto-b",
"threshold": 0.35,
"limit": 5,
},
)
assert response.status_code == 200
suggestions = response.json()
assert suggestions
# Top suggestion should be the Person→PersonRecord pair (highest label similarity).
top = suggestions[0]
assert "Person" in top["source_label"]
assert "Person" in top["target_label"]
# Results must be sorted descending by score.
assert suggestions == sorted(suggestions, key=lambda item: item["score"], reverse=True)
def test_health_returns_dimensions_and_issues(client):
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a")
assert response.status_code == 200
payload = response.json()
assert payload["total_score"] >= 0
assert {dimension["key"] for dimension in payload["dimensions"]} == {
"completeness",
"consistency",
"shacl",
"alignment",
"documentation",
}
assert isinstance(payload["issues"], list)
def test_shacl_generate_and_shapes(client):
response = client.post(
"/api/ontology/shacl/generate",
json={"uri": "http://example.org/onto-a", "quality_tier": "strict"},
)
assert response.status_code == 200
payload = response.json()
assert "sh:NodeShape" in payload["shacl_turtle"]
assert payload["shape_count"] >= 1
shapes = client.get("/api/ontology/shacl/shapes?uri=http%3A%2F%2Fexample.org%2Fonto-a")
assert shapes.status_code == 200
assert shapes.json()["shapes"]
def test_shacl_validate_returns_unavailable(client):
response = client.post(
"/api/ontology/shacl/validate",
json={
"uri": "http://example.org/onto-a",
"shacl_turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> .",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "unavailable", "stub must not report conforms=True before validation is wired"
assert payload["conforms"] is False
assert isinstance(payload["violations"], list)
def test_shacl_validate_rejects_empty_turtle(client):
response = client.post(
"/api/ontology/shacl/validate",
json={"uri": "http://example.org/onto-a", "shacl_turtle": " "},
)
assert response.status_code == 422
def test_health_returns_404_for_unknown_ontology(client):
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fnot-loaded.example%2Fonto")
assert response.status_code == 404
def test_health_shacl_dimension_is_zero_when_unavailable(client):
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
shacl_dim = next(d for d in payload["dimensions"] if d["key"] == "shacl")
assert shacl_dim["status"] == "unavailable"
assert shacl_dim["score"] == 0.0
# Total score must NOT include the unavailable dimension in its average.
scoreable = [d for d in payload["dimensions"] if d["status"] != "unavailable"]
expected_total = round(sum(d["score"] for d in scoreable) / len(scoreable), 1)
assert payload["total_score"] == expected_total
def test_delete_unknown_alignment_returns_404(client):
response = client.delete("/api/ontology/alignments?id=does-not-exist")
assert response.status_code == 404
def test_alignment_upsert_is_idempotent(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.80,
}
first = client.post("/api/ontology/alignments", json=payload).json()
updated_payload = {**payload, "confidence": 0.95}
second = client.post("/api/ontology/alignments", json=updated_payload).json()
assert first["id"] == second["id"], "upsert must reuse the same deterministic ID"
assert second["confidence"] == 0.95
assert second["created_at"] == first["created_at"], "created_at must not change on update"
listed = client.get("/api/ontology/alignments").json()
assert len(listed) == 1
def test_alignment_accepts_external_uri(client):
payload = {
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://schema.org/Person", # not in local graph
"relation": "owl:equivalentClass",
"confidence": 0.75,
}
response = client.post("/api/ontology/alignments", json=payload)
assert response.status_code == 200
alignment = response.json()
assert alignment["target_label"] == "Person" # derived from URI fragment
def test_suggest_alignments_returns_embedding_similarity(client):
response = client.post(
"/api/ontology/suggest-alignments",
json={
"source_ontology_uri": "http://example.org/onto-a",
"target_ontology_uri": "http://example.org/onto-b",
"threshold": 0.20,
"limit": 10,
},
)
assert response.status_code == 200
suggestions = response.json()
assert suggestions
# When sklearn is available, embedding_similarity should be populated.
top = suggestions[0]
assert top["embedding_similarity"] is not None, (
"TF-IDF embedding similarity must be returned when sklearn is installed"
)
# Combined score must be a weighted blend, not purely the label score.
assert top["score"] != top["label_similarity"] or top["embedding_similarity"] == top["label_similarity"]
def test_shacl_validate_rejects_invalid_turtle_syntax(client):
response = client.post(
"/api/ontology/shacl/validate",
json={
"uri": "http://example.org/onto-a",
"shacl_turtle": "this is not valid turtle !!!",
},
)
assert response.status_code == 422
def test_health_alignment_coverage_uses_set_lookup(client):
# Create an alignment first so coverage score can be non-zero.
client.post("/api/ontology/alignments", json={
"source_uri": "http://example.org/onto-a#Person",
"target_uri": "http://example.org/onto-b#PersonRecord",
"relation": "owl:equivalentClass",
"confidence": 0.9,
})
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
alignment_dim = next(d for d in payload["dimensions"] if d["key"] == "alignment")
assert alignment_dim["score"] > 0.0, "alignment coverage must be non-zero after recording an alignment"
+78
View File
@@ -0,0 +1,78 @@
import os
import subprocess
import sys
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _run_python_with_blocked_modules(
code: str,
blocked_modules: tuple[str, ...],
) -> subprocess.CompletedProcess[str]:
blocker = f"""
import importlib.abc
import sys
BLOCKED_MODULES = {blocked_modules!r}
class OptionalDependencyBlocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
root_name = fullname.split(".", 1)[0]
if root_name in BLOCKED_MODULES:
err = ModuleNotFoundError(f"No module named '{{root_name}}'")
err.name = root_name
raise err
return None
sys.meta_path.insert(0, OptionalDependencyBlocker())
"""
env = os.environ.copy()
env["PYTHONPATH"] = str(REPO_ROOT)
env["PYTHONDONTWRITEBYTECODE"] = "1"
return subprocess.run(
[sys.executable, "-c", textwrap.dedent(blocker + "\n" + code)],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_file_ingestion_imports_without_optional_backends() -> None:
result = _run_python_with_blocked_modules(
"""
from semantica.ingest import FileIngestor, ingest_file
print(FileIngestor.__name__, callable(ingest_file))
""",
("git", "bs4"),
)
assert result.returncode == 0, result.stderr
assert "FileIngestor True" in result.stdout
def test_repository_ingestion_reports_missing_gitpython_when_used() -> None:
result = _run_python_with_blocked_modules(
"""
from semantica.ingest import ingest_repository
try:
ingest_repository("https://example.com/repo.git")
except Exception as exc:
print(type(exc).__name__, exc)
else:
raise SystemExit("expected repository ingestion to fail without GitPython")
""",
("git",),
)
assert result.returncode == 0, result.stderr
assert "ConfigurationError" in result.stdout
assert "Repository ingestion" in result.stdout
assert "GitPython" in result.stdout
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _run_python(code: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["PYTHONPATH"] = str(REPO_ROOT)
env["PYTHONDONTWRITEBYTECODE"] = "1"
return subprocess.run(
[sys.executable, "-c", textwrap.dedent(code)],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_core_extractors_import_from_package() -> None:
from semantica.semantic_extract import (
NERExtractor,
RelationExtractor,
TripletExtractor,
)
assert NERExtractor.__name__ == "NERExtractor"
assert RelationExtractor.__name__ == "RelationExtractor"
assert TripletExtractor.__name__ == "TripletExtractor"
def test_triple_extractor_alias_imports() -> None:
from semantica.semantic_extract import TripleExtractor, TripletExtractor
assert TripleExtractor is TripletExtractor
def test_method_dispatchers_import_without_extractor_cycle() -> None:
from semantica.semantic_extract.methods import (
get_entity_method,
get_relation_method,
get_triplet_method,
)
assert callable(get_entity_method("pattern"))
assert callable(get_relation_method("pattern"))
assert callable(get_triplet_method("pattern"))
def test_import_order_methods_before_extractors() -> None:
result = _run_python(
"""
from semantica.semantic_extract.methods import get_entity_method
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
print(get_entity_method("pattern").__name__, NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__)
"""
)
assert result.returncode == 0, result.stderr
assert "extract_entities_pattern NERExtractor RelationExtractor TripletExtractor" in result.stdout
def test_import_order_extractors_before_methods() -> None:
result = _run_python(
"""
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
from semantica.semantic_extract.methods import get_entity_method
print(NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__, get_entity_method("pattern").__name__)
"""
)
assert result.returncode == 0, result.stderr
assert "NERExtractor RelationExtractor TripletExtractor extract_entities_pattern" in result.stdout
def test_legacy_type_imports_still_work() -> None:
from semantica.semantic_extract.ner_extractor import Entity
from semantica.semantic_extract.relation_extractor import Relation
from semantica.semantic_extract.triplet_extractor import Triplet
from semantica.semantic_extract.types import (
Entity as SharedEntity,
Relation as SharedRelation,
Triplet as SharedTriplet,
)
assert Entity is SharedEntity
assert Relation is SharedRelation
assert Triplet is SharedTriplet
def test_core_package_imports_do_not_require_yaml() -> None:
result = _run_python(
"""
import importlib.abc
import sys
class OptionalDependencyBlocker(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.split(".", 1)[0] == "yaml":
raise ModuleNotFoundError("No module named 'yaml'")
return None
sys.meta_path.insert(0, OptionalDependencyBlocker())
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
print(NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__)
"""
)
assert result.returncode == 0, result.stderr
assert "NERExtractor RelationExtractor TripletExtractor" in result.stdout
@@ -10,6 +10,10 @@ import importlib.util
# Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
# Save originals before injecting mocks so they can be restored after import.
_MOCKED_MODULES = ["spacy", "instructor", "groq", "openai", "sentence_transformers", "transformers"]
_original_modules = {k: sys.modules.get(k) for k in _MOCKED_MODULES}
# Mock external dependencies with __spec__ for importlib checks
mock_spacy = MagicMock()
mock_spacy.__spec__ = MagicMock()
@@ -33,6 +37,16 @@ from semantica.semantic_extract import NERExtractor
from semantica.semantic_extract.methods import extract_entities_llm, _extract_entities_chunked, extract_relations_llm, extract_triplets_llm
from semantica.semantic_extract.providers import BaseProvider
# Restore real sys.modules entries now that the mocks have served their purpose
# for the imports above. Leaving them in place would poison other test modules
# (e.g. test_pr482_deepseek_openai) that need the real packages at test run time.
for _key, _original in _original_modules.items():
if _original is None:
sys.modules.pop(_key, None)
else:
sys.modules[_key] = _original
class EntitiesResponse(BaseModel):
entities: List[dict]