Compare commits

...
50 Commits
Author SHA1 Message Date
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
Mohd KaifandClaude Sonnet 4.6 7884d71e23 feat(explorer): add welcome screen and fix root path Invalid path error (#501)
- Add WelcomeScreen shown on app load; SKE brand button navigates back
- Fix serve_spa: empty root path was hitting dot-guard returning 400
  Invalid path instead of index.html or a welcome JSON response

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

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

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

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

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

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

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

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

Must-fix items resolved:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #478

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 15:08:59 +05:30
57 changed files with 8218 additions and 904 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v5
with: with:
path: ./site path: ./site
+7
View File
@@ -111,5 +111,12 @@ sample_data/
# Test Results # Test Results
test_results.txt test_results.txt
# Frontend workspace artifacts
semantica-explorer/
node_modules/
# Frontend build artifacts (generated by Vite — do not track in git) # Frontend build artifacts (generated by Vite — do not track in git)
semantica/static/ semantica/static/
# Local graph explorer test datasets
demo_out/
+71
View File
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- **Feature: Distance Intelligence** (closes #502 by @KaifAhmad1):
- **Context layer** — `ContextGraph.get_neighbors()` gains `include_distance_metadata=False`; when enabled adds `distance_band`, `confidence_decay`, and `path_to_anchor` per result. New `get_neighbor_distances()` returns neighbors sorted by `(hop, -decay)` with optional `min_confidence` filter. `AgentContext.retrieve()` / `find_precedents()` accept `anchor_node`, `max_hops`, `proximity_weight`, `min_confidence_decay` and blend graph proximity with semantic score as `combined_score = (1 w) × semantic + w × proximity`.
- **Path enrichment (FR-4)** — `GET /api/graph/node/{id}/path` now returns `semantic_similarity`, `path_coherence_score`, `confidence_decay` (O(L) via pre-built edge-weight index), `bottleneck_node`, `alternative_path_count`, and `interpretation`. All fields optional; zero breaking changes.
- **Distance matrix (FR-6)** — `POST /api/graph/distance-matrix` accepts up to 50 nodes and metric `hops | weighted | semantic`. Returns N × N matrix (upper-triangle computed, lower mirrored), unreachable pairs, and `computation_time_ms`.
- **Semantic neighborhood (FR-3 backend)** — `GET /api/graph/node/{id}/semantic-neighborhood?top_k=N` returns the N most similar nodes with `id`, `type`, `content`, `similarity`, `hop_distance`.
- **Causal distance (FR-8)** — `GET /api/decisions/causal-distance?source=&target=` traverses only causal-typed edges and returns `CausalDistanceReport` with path, hop count, `confidence_decay`, `weakest_link`, and interpretation.
- **Temporal distance history (FR-9)** — `GET /api/temporal/distance-history` samples 11 evenly-spaced snapshots across the graph's time range and emits `convergence | divergence | disconnected | reconnected` events.
- **Distance-enriched export (FR-10)** — `POST /api/export/distance-enriched` streams pairwise hop/weighted/semantic/band/centrality metrics as CSV or JSONL. `node_subset` capped at 200 nodes.
- **Explorer UI** — Path inspector panel (`GraphInspectorPanel.tsx`) shows a distance band chip, progress-bar metric cards (decay, similarity, coherence), bottleneck node highlight, and interpretation text. Toolbar gains Ego Mode (client-side BFS depth-of-field fading, depth slider 18), Structural overlay (edges colored by hop distance), Semantic overlay (edges colored by cosine similarity), and Heatmap (nodes colored green → red by hop distance). Ego and heatmap share a single merged `useEffect` to prevent `restoreNodeColors()` races.
- **Tests** — 57 new tests in `tests/context/test_distance_intelligence.py`; 18 targeted regression tests in `tests/_smoke_review_fixes.py`.
- **Fix: Distance Intelligence — code review regressions** (PR #502 follow-up by @KaifAhmad1):
- `GraphWorkspace.tsx` semantic fetch used `?limit=50`; corrected to `?top_k=50` to match the backend param (bug_001). Response type widened to full `SemanticNeighborhoodResponse` shape (bug_002).
- `ContextGraph.get_neighbors()` was embedding distance metadata unconditionally, breaking existing callers; gated behind `include_distance_metadata=False` default (bug_003).
- `weakest_link` dict key standardised from `weight``edge_weight` across `CausalChainAnalyzer` and `CausalDistanceReport` (bug_004).
- Temporal distance history sampling replaced `timetuple()[:6]` reconstruction with `min_bound + timedelta(seconds=...)` (bug_005).
- Confidence decay in `find_path` was O(E × L); replaced with a single O(E) edge-weight index built before the hop loop, with undirected mirroring (bug_006).
- `AgentContext._apply_proximity_metadata()` was overwriting the original record `"id"` with the graph node id; stored as `"graph_node_id"` instead (bug_007).
- Path highlight sweep animation used a shared `sweepTimer`; stale callbacks fired after cancellation. Added `sweepGeneration` counter — callbacks no-op if generation no longer matches (bug_008).
- `POST /api/export/distance-enriched` now rejects `node_subset` larger than 200 nodes with HTTP 413 (sec_001).
- `POST /api/graph/distance-matrix` now computes only the upper triangle and mirrors results, halving computation cost (sec_002).
- Ego mode and heatmap `useEffect` hooks merged into one to eliminate concurrent `restoreNodeColors()` race (qual_001).
- Bare `except Exception: pass` blocks in `find_path` and `semantic_neighborhood` replaced with `logger.debug(...)` (qual_002).
- Duplicated `_distance_band()` static method removed from `CausalChainAnalyzer` and `AgentContext`; both now use `classify_path_distance` from `semantica.utils.helpers` (qual_003).
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
- parallel-edge aggregation in full view
- collapse behavior preserving active-path neighbors
- grouped community-node/community-edge projection behavior
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24471 ms; a session with 500 k nodes was effectively unusable.
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (212 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (01 hops), `"near"` (23), `"mid-range"` (46), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops). - **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (01 hops), `"near"` (23), `"mid-range"` (46), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`). - **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-builder FROM node:25-alpine AS frontend-builder
WORKDIR /app/semantica-explorer WORKDIR /app/semantica-explorer
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
RUN npm run build RUN npm run build
FROM python:3.12-slim AS runtime FROM python:3.14-slim AS runtime
WORKDIR /app WORKDIR /app
+855 -313
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs" "test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
@@ -43,6 +44,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^5.4.0" "vite": "^5.4.0"
+28 -3
View File
@@ -15,7 +15,7 @@ const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/Enti
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab }))); const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab }))); const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage'; type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
type ExploreView = 'graph' | 'vocabulary'; type ExploreView = 'graph' | 'vocabulary';
type AnalyzeView = 'sparql' | 'reasoning'; type AnalyzeView = 'sparql' | 'reasoning';
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve'; type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
@@ -311,14 +311,39 @@ function WorkspaceFallback() {
return <div className="workspace-loading">Loading workspace</div>; return <div className="workspace-loading">Loading workspace</div>;
} }
function WelcomeScreen() {
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-muted)',
}}>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
Welcome to Semantica
</h1>
<p style={{ margin: 0, fontSize: 14 }}>
Select a workspace from the sidebar to get started.
</p>
</div>
);
}
export default function App() { export default function App() {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore'); const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
const [exploreView, setExploreView] = useState<ExploreView>('graph'); const [exploreView, setExploreView] = useState<ExploreView>('graph');
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning'); const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
const [enrichView, setEnrichView] = useState<EnrichView>('import'); const [enrichView, setEnrichView] = useState<EnrichView>('import');
const [manageView, setManageView] = useState<ManageView>('lineage'); const [manageView, setManageView] = useState<ManageView>('lineage');
const renderWorkspace = () => { const renderWorkspace = () => {
if (activeWorkspace === 'welcome') {
return <WelcomeScreen />;
}
if (activeWorkspace === 'explore') { if (activeWorkspace === 'explore') {
return ( return (
<WorkspaceShell <WorkspaceShell
@@ -451,7 +476,7 @@ export default function App() {
<style>{shellStyles}</style> <style>{shellStyles}</style>
<div className="app-shell"> <div className="app-shell">
<aside className="app-rail"> <aside className="app-rail">
<div className="brand-pill" title="Semantica Knowledge Explorer">SKE</div> <button className="brand-pill" title="Semantica Knowledge Explorer" onClick={() => setActiveWorkspace('welcome')} style={{ cursor: 'pointer', border: '1px solid rgba(127,208,255,0.18)' }}>SKE</button>
{navItems.map(({ id, label, hint, icon: Icon }) => ( {navItems.map(({ id, label, hint, icon: Icon }) => (
<button <button
key={id} key={id}
+10
View File
@@ -42,6 +42,10 @@ export interface NodeAttributes {
haloColor?: string; haloColor?: string;
labelVisibilityPolicy?: GraphLabelVisibilityPolicy; labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
highlighted?: boolean; highlighted?: boolean;
communityId?: string;
isCommunityGroup?: boolean;
memberCount?: number;
anchorNodeId?: string | null;
nodeType: string; nodeType: string;
content: string; content: string;
@@ -74,6 +78,12 @@ export interface EdgeAttributes {
parallelIndex?: number; parallelIndex?: number;
parallelCount?: number; parallelCount?: number;
familySize?: number; familySize?: number;
rawEdgeIds?: string[];
isAggregated?: boolean;
aggregateCount?: number;
dominantEdgeType?: string;
representativeWeight?: number;
bundleKind?: "parallel" | "bidirectional" | "community";
edgeType: string; edgeType: string;
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { graph } from "../../store/graphStore"; import { graph } from "../../store/graphStore";
import { GRAPH_THEME } from "./graphTheme"; import { GRAPH_THEME, withAlpha } from "./graphTheme";
import type { GraphSelectedNodeKind } from "./types";
export type LinkPrediction = { export type LinkPrediction = {
target: string; target: string;
@@ -16,10 +17,21 @@ export type PathResponse = {
total_weight: number; total_weight: number;
hop_count: number; hop_count: number;
distance_band: "direct" | "near" | "mid-range" | "distant"; 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 { export interface GraphInspectorPanelProps {
nodeId: string; nodeId: string;
inspectableNodeId?: string | null;
selectedNodeKind?: GraphSelectedNodeKind;
canActivateFocused?: boolean;
focusedUnavailableReason?: string | null;
predictions: LinkPrediction[]; predictions: LinkPrediction[];
predictionType: string; predictionType: string;
onPredictionTypeChange: (value: string) => void; onPredictionTypeChange: (value: string) => void;
@@ -41,6 +53,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
.map((key) => ({ key, value: properties[key] })); .map((key) => ({ key, value: properties[key] }));
} }
/* ─── Path Distance Intelligence Panel ──────────────────────────── */
const BAND_COLORS: Record<string, string> = {
direct: "#3fb950",
near: "#79c0ff",
"mid-range": "#e3b341",
distant: "#ff7b72",
};
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
const hasMetrics =
result.confidence_decay != null ||
result.semantic_similarity != null ||
result.path_coherence_score != null ||
result.bottleneck_node ||
result.interpretation;
if (!hasMetrics) return null;
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
{/* distance band + alt paths */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
<span
style={{
padding: "3px 8px",
borderRadius: 999,
background: withAlpha(bandColor, 0.14),
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
color: bandColor,
fontSize: 11,
fontWeight: 700,
}}
>
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
</span>
{(result.alternative_path_count ?? 0) > 0 && (
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
)}
</div>
{/* metric grid */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{result.confidence_decay != null && (
<div style={metricCardStyle}>
<div style={metricLabelStyle}>Confidence Decay</div>
<div
style={{
...metricValueStyle,
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
}}
>
{(result.confidence_decay * 100).toFixed(1)}%
</div>
<div style={metricBarTrackStyle}>
<div
style={{
...metricBarFillStyle,
width: `${result.confidence_decay * 100}%`,
background:
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
}}
/>
</div>
</div>
)}
{result.semantic_similarity != null && (
<div style={metricCardStyle}>
<div style={metricLabelStyle}>Semantic Sim.</div>
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
{(result.semantic_similarity * 100).toFixed(1)}%
</div>
<div style={metricBarTrackStyle}>
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
</div>
</div>
)}
{result.path_coherence_score != null && (
<div style={metricCardStyle}>
<div style={metricLabelStyle}>Path Coherence</div>
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
{(result.path_coherence_score * 100).toFixed(1)}%
</div>
</div>
)}
{result.bottleneck_node && (
<div style={metricCardStyle}>
<div style={metricLabelStyle}>Bottleneck</div>
<div
style={{
...metricValueStyle,
color: "#e3b341",
fontSize: 11,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={result.bottleneck_node}
>
{getNodeLabel(result.bottleneck_node)}
</div>
</div>
)}
</div>
{/* interpretation */}
{result.interpretation && (
<div
style={{
padding: "8px 10px",
background: "rgba(88,166,255,0.06)",
borderRadius: 8,
border: "1px solid rgba(88,166,255,0.14)",
color: "#a0b4cc",
fontSize: 12,
lineHeight: 1.5,
}}
>
{result.interpretation}
</div>
)}
</div>
);
}
/* ─── Path Flow Visualizer ──────────────────────────────────────── */ /* ─── Path Flow Visualizer ──────────────────────────────────────── */
function getNodeLabel(nodeId: string): string { function getNodeLabel(nodeId: string): string {
@@ -78,11 +216,13 @@ function PathFlowViz({
path, path,
edgeIds, edgeIds,
totalWeight, totalWeight,
bottleneckNodeId,
onFocusNode, onFocusNode,
}: { }: {
path: string[]; path: string[];
edgeIds?: string[]; edgeIds?: string[];
totalWeight: number; totalWeight: number;
bottleneckNodeId?: string | null;
onFocusNode?: (nodeId: string) => void; onFocusNode?: (nodeId: string) => void;
}) { }) {
if (path.length === 0) { if (path.length === 0) {
@@ -105,10 +245,13 @@ function PathFlowViz({
{/* Node chip */} {/* Node chip */}
<button <button
onClick={() => onFocusNode?.(nodeId)} onClick={() => onFocusNode?.(nodeId)}
title={`Focus: ${nodeId}`} title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
style={{ style={{
...pathNodeChipStyle, ...pathNodeChipStyle,
cursor: onFocusNode ? "pointer" : "default", cursor: onFocusNode ? "pointer" : "default",
...(nodeId === bottleneckNodeId
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
: {}),
}} }}
> >
<span style={pathNodeIndexStyle}>{index + 1}</span> <span style={pathNodeIndexStyle}>{index + 1}</span>
@@ -148,6 +291,10 @@ function PathFlowViz({
export function GraphInspectorPanel({ export function GraphInspectorPanel({
nodeId, nodeId,
inspectableNodeId,
selectedNodeKind = "none",
canActivateFocused = false,
focusedUnavailableReason = null,
predictions, predictions,
predictionType, predictionType,
onPredictionTypeChange, onPredictionTypeChange,
@@ -173,7 +320,38 @@ export function GraphInspectorPanel({
); );
} }
const attributes = graph.getNodeAttributes(nodeId) as { const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
const directlyInspectable = graph.hasNode(nodeId);
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
if (!effectiveNodeId) {
return (
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
</div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{nodeId}
</h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
</div>
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? "Activate Focused mode to resolve this grouped selection to its canonical node."
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
</div>
</div>
</aside>
);
}
const attributes = graph.getNodeAttributes(effectiveNodeId) as {
color?: string; color?: string;
content?: string; content?: string;
label?: string; label?: string;
@@ -196,12 +374,26 @@ export function GraphInspectorPanel({
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}> <div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} /> <span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span> <span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
</span>
</div> </div>
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}> <h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
{String(attributes?.label ?? nodeId)} {String(attributes?.label ?? effectiveNodeId)}
</h3> </h3>
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div> <div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
{groupedDisplaySelection ? nodeId : effectiveNodeId}
</div>
{groupedDisplaySelection ? (
<div style={groupedSelectionNoticeStyle}>
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
{canActivateFocused
? `Canonical node available: ${effectiveNodeId}`
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
</div>
</div>
) : null}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
{attributes?.valid_from || attributes?.valid_until ? ( {attributes?.valid_from || attributes?.valid_until ? (
<span style={subtleChipStyle}>temporal</span> <span style={subtleChipStyle}>temporal</span>
@@ -226,7 +418,7 @@ export function GraphInspectorPanel({
<button <button
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }} style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
onClick={onRunPredictions} onClick={onRunPredictions}
disabled={isRunningPredictions} disabled={isRunningPredictions || !actionNodeId}
> >
{isRunningPredictions ? ( {isRunningPredictions ? (
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} /> <Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
@@ -234,10 +426,10 @@ export function GraphInspectorPanel({
{isRunningPredictions ? "Running…" : "Run Link Prediction"} {isRunningPredictions ? "Running…" : "Run Link Prediction"}
</button> </button>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}> <button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
Provenance JSON Provenance JSON
</button> </button>
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}> <button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
Provenance MD Provenance MD
</button> </button>
</div> </div>
@@ -259,15 +451,19 @@ export function GraphInspectorPanel({
placeholder="Target node ID" placeholder="Target node ID"
style={inputStyle} style={inputStyle}
/> />
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button> <button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
{pathResult?.path?.length ? ( {pathResult?.path?.length ? (
<PathFlowViz <>
path={pathResult.path} <PathFlowViz
edgeIds={pathResult.edge_ids} path={pathResult.path}
totalWeight={pathResult.total_weight} edgeIds={pathResult.edge_ids}
onFocusNode={onFocusNode} totalWeight={pathResult.total_weight}
/> bottleneckNodeId={pathResult.bottleneck_node}
onFocusNode={onFocusNode}
/>
<PathDistanceIntelPanel result={pathResult} />
</>
) : ( ) : (
<div style={emptyTextStyle}> <div style={emptyTextStyle}>
Choose a target or click a candidate prediction to prepare a path trace. Choose a target or click a candidate prediction to prepare a path trace.
@@ -378,6 +574,14 @@ const inputStyle: CSSProperties = {
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
}; };
const groupedSelectionNoticeStyle: CSSProperties = {
marginTop: 12,
padding: "10px 12px",
background: "rgba(88,166,255,0.08)",
border: "1px solid rgba(88,166,255,0.2)",
borderRadius: 12,
};
const actionButtonStyle: CSSProperties = { const actionButtonStyle: CSSProperties = {
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))", background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
color: "#fff", color: "#fff",
@@ -505,3 +709,41 @@ const pathEdgeLabelStyle: CSSProperties = {
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}; };
const metricCardStyle: CSSProperties = {
background: "rgba(0,0,0,0.18)",
borderRadius: 8,
padding: "8px 10px",
border: "1px solid rgba(255,255,255,0.05)",
display: "flex",
flexDirection: "column",
gap: 3,
};
const metricLabelStyle: CSSProperties = {
color: "rgba(88,166,255,0.65)",
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
};
const metricValueStyle: CSSProperties = {
fontSize: 14,
fontWeight: 700,
color: "#e6edf3",
};
const metricBarTrackStyle: CSSProperties = {
height: 3,
borderRadius: 999,
background: "rgba(255,255,255,0.07)",
overflow: "hidden",
marginTop: 4,
};
const metricBarFillStyle: CSSProperties = {
height: "100%",
borderRadius: 999,
transition: "width 300ms ease",
};
@@ -3,6 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore"; import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
import { SigmaSceneAdapter } from "./SigmaSceneAdapter"; import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
import { createGraphLoadProgress } from "./graphLoading"; import { createGraphLoadProgress } from "./graphLoading";
import { resolveDisplayGraph } from "./graphSceneState";
import { import {
chooseColorAccessor, chooseColorAccessor,
colorForNodeKey, colorForNodeKey,
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
lensMode: "neighborhood", lensMode: "neighborhood",
effectQuality: "bounded", effectQuality: "bounded",
}; };
const EMPTY_PATH: string[] = [];
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:"); const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
@@ -67,6 +69,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
valid_until: attributes.valid_until ?? null, valid_until: attributes.valid_until ?? null,
properties: attributes.properties ?? {}, properties: attributes.properties ?? {},
neighborCount: graph.neighbors(nodeId).length, neighborCount: graph.neighbors(nodeId).length,
visibleNeighborCount: graph.neighbors(nodeId).length,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
}; };
} }
@@ -113,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
const prevActiveIdsRef = useRef<Set<string>>(new Set()); const prevActiveIdsRef = useRef<Set<string>>(new Set());
const [graphVersion, setGraphVersion] = useState(0); const [graphVersion, setGraphVersion] = useState(0);
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime"); const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
const displayResult = useMemo(
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
[activePath, graphVersion, selectedNodeId, viewMode],
);
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]); const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
@@ -447,9 +457,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
<SigmaSceneAdapter <SigmaSceneAdapter
ref={sceneRef} ref={sceneRef}
onNodeSelect={onNodeSelect} onNodeSelect={onNodeSelect}
graphVersion={graphVersion}
graphReady={Boolean(snapshot)}
displayGraph={displayResult.graph}
displayMeta={displayResult.meta}
displayState={displayResult.state}
selectedEdgeId="" selectedEdgeId=""
selectedNodeId={selectedNodeId} selectedNodeId={selectedNodeId}
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
activePath={activePath} activePath={activePath}
activePathEdgeIds={EMPTY_PATH}
effectsState={STAGE_EFFECTS_STATE} effectsState={STAGE_EFFECTS_STATE}
isLayoutRunning={isLayoutRunning} isLayoutRunning={isLayoutRunning}
onLayoutRunningChange={onLayoutRunningChange} onLayoutRunningChange={onLayoutRunningChange}
File diff suppressed because it is too large Load Diff
@@ -139,6 +139,10 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
valid_until: node.valid_until ?? null, valid_until: node.valid_until ?? null,
properties: node.properties ?? {}, properties: node.properties ?? {},
neighborCount, neighborCount,
visibleNeighborCount: neighborCount,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: neighborCount > 8,
}; };
} }
@@ -427,6 +431,10 @@ export function GraphWorkspaceShell() {
valid_until: null, valid_until: null,
properties: searchNode.properties ?? {}, properties: searchNode.properties ?? {},
neighborCount: 0, neighborCount: 0,
visibleNeighborCount: 0,
collapsedNeighborCount: 0,
isNeighborhoodCollapsed: false,
canCollapseNeighborhood: false,
} }
: null; : null;
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]); }, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
@@ -555,6 +563,19 @@ export function GraphWorkspaceShell() {
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`; return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
}, [viewMode, visibleSelectedNode]); }, [viewMode, visibleSelectedNode]);
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
if (nextViewMode === "focused") {
if (!selectedNodeId) {
return;
}
setViewMode("focused");
setIsLayoutRunning(false);
return;
}
setViewMode("full");
}, [selectedNodeId]);
const showLoadingOverlay = const showLoadingOverlay =
isLoading isLoading
|| isFetching || isFetching
@@ -631,8 +652,8 @@ export function GraphWorkspaceShell() {
<div className="graph-toggle-cluster"> <div className="graph-toggle-cluster">
{selectedNodeId ? ( {selectedNodeId ? (
<> <>
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button> <button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button> <button onClick={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
</> </>
) : ( ) : (
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span> <span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
@@ -24,6 +24,8 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
fitView: () => canvasRef.current?.fitView(), fitView: () => canvasRef.current?.fitView(),
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId), focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
zoomIn: () => canvasRef.current?.zoomIn(),
zoomOut: () => canvasRef.current?.zoomOut(),
getRuntime: () => runtimeRef.current, getRuntime: () => runtimeRef.current,
setLayoutRunning: onLayoutRunningChange setLayoutRunning: onLayoutRunningChange
? (running: boolean) => { ? (running: boolean) => {
@@ -5,11 +5,21 @@ export const focusCameraBehavior: GraphBehavior = {
attach: () => {}, attach: () => {},
detach: () => {}, detach: () => {},
performAction: (context, action) => { performAction: (context, action) => {
if (action.type !== "focusNode") { if (action.type === "focusNode") {
return false; context.focusNodeInView(action.nodeId);
return true;
} }
context.focusNodeInView(action.nodeId); if (action.type === "centerSelection") {
return true; context.centerSelectionInView(action.nodeId);
return true;
}
if (action.type === "centerGroupedSelection") {
context.centerGroupedSelectionInView(action.nodeId);
return true;
}
return false;
}, },
}; };
@@ -1,13 +1,37 @@
import type { GraphBehavior } from "./types"; import type { GraphBehavior } from "./types";
const SWEEP_TICKS = 6;
const SWEEP_INTERVAL_MS = 60;
export function createPathHighlightBehavior(): GraphBehavior { export function createPathHighlightBehavior(): GraphBehavior {
let lastPathSignature = ""; 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 { return {
id: "path-highlight", id: "path-highlight",
attach: () => {}, attach: () => {},
detach: () => { detach: (context) => {
cancelSweep();
lastPathSignature = ""; lastPathSignature = "";
context.sigma.refresh();
}, },
onStateChange: (context, interactionState) => { onStateChange: (context, interactionState) => {
const nextPathSignature = interactionState.activePath.join("::"); const nextPathSignature = interactionState.activePath.join("::");
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
} }
lastPathSignature = nextPathSignature; lastPathSignature = nextPathSignature;
cancelSweep();
context.sigma.refresh(); context.sigma.refresh();
// Animate intermediate nodes lighting up sequentially
if (interactionState.activePath.length > 2) {
scheduleSweep(context.sigma, 0, sweepGeneration);
}
}, },
}; };
} }
@@ -1,23 +1,36 @@
import type { GraphBehavior } from "./types"; import type { GraphBehavior } from "./types";
export function createSearchFocusBehavior(): GraphBehavior { export function createSearchFocusBehavior(): GraphBehavior {
let lastFocusedNodeId = ""; let lastSelectedNodeId = "";
let lastViewMode = "";
return { return {
id: "search-focus", id: "search-focus",
attach: () => {}, attach: () => {},
detach: () => { detach: () => {
lastFocusedNodeId = ""; lastSelectedNodeId = "";
lastViewMode = "";
}, },
onStateChange: (context, interactionState) => { onStateChange: (context, interactionState) => {
const nextFocusedNodeId = interactionState.focusedNodeId; const nextSelectedNodeId = interactionState.selectedNodeId;
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) { const nextViewMode = interactionState.viewMode;
lastFocusedNodeId = nextFocusedNodeId; if (nextViewMode !== lastViewMode) {
lastViewMode = nextViewMode;
lastSelectedNodeId = nextSelectedNodeId;
return;
}
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
lastSelectedNodeId = nextSelectedNodeId;
lastViewMode = nextViewMode;
return; return;
} }
lastFocusedNodeId = nextFocusedNodeId; lastSelectedNodeId = nextSelectedNodeId;
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId }); lastViewMode = nextViewMode;
context.dispatchAction({
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
nodeId: nextSelectedNodeId,
});
}, },
}; };
} }
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
export type GraphBehaviorActionRequest = export type GraphBehaviorActionRequest =
| { type: "fitView" } | { type: "fitView" }
| { type: "focusNode"; nodeId: string }; | { type: "focusNode"; nodeId: string }
| { type: "centerSelection"; nodeId: string }
| { type: "centerGroupedSelection"; nodeId: string };
export interface GraphBehaviorContext { export interface GraphBehaviorContext {
sigma: Sigma; sigma: Sigma;
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
onNodeSelectionChange: (nodeId: string) => void; onNodeSelectionChange: (nodeId: string) => void;
onEdgeSelectionChange: (edgeId: string) => void; onEdgeSelectionChange: (edgeId: string) => void;
focusNodeInView: (nodeId: string) => void; focusNodeInView: (nodeId: string) => void;
centerSelectionInView: (nodeId: string) => void;
centerGroupedSelectionInView: (nodeId: string) => void;
fitCurrentView: () => void; fitCurrentView: () => void;
dispatchAction: (action: GraphBehaviorActionRequest) => void; dispatchAction: (action: GraphBehaviorActionRequest) => void;
} }
@@ -1,7 +1,8 @@
import type { GraphBehavior } from "./types"; import type { GraphBehavior } from "./types";
import type { GraphViewMode } from "../types";
export function createViewModeSwitchBehavior(): GraphBehavior { export function createViewModeSwitchBehavior(): GraphBehavior {
let lastViewMode: "focused" | "full" | null = null; let lastViewMode: GraphViewMode | null = null;
return { return {
id: "view-mode-switch", id: "view-mode-switch",
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
} }
lastViewMode = interactionState.viewMode; lastViewMode = interactionState.viewMode;
const nextFocusedNodeId = interactionState.focusedNodeId;
if (interactionState.focusedNodeId) { if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId }); context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
return; return;
} }
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted"; type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted"; type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
export interface GraphTheme { export interface GraphTheme {
palette: { palette: {
@@ -190,6 +191,35 @@ export interface GraphTheme {
motion: { motion: {
cameraMs: number; cameraMs: number;
}; };
grouped: {
initialLayout: {
innerRadius: number;
ringSpacing: number;
minNodeSpacing: number;
nodePadding: number;
overlapIterations: number;
primaryLabelCount: number;
};
style: {
nodeSizeScale: number;
nodeBorderBoost: number;
fillAlpha: number;
shellAlpha: number;
edgeSizeScale: number;
edgeAlpha: number;
glowAlpha: number;
edgeVisibilityRatio: number;
topIncidentEdges: number;
};
layout: {
iterations: number;
gravity: number;
scalingRatio: number;
edgeWeightInfluence: number;
slowDown: number;
settleMs: number;
};
};
effects: { effects: {
pathPulse: { pathPulse: {
minZoomTier: GraphZoomTier; minZoomTier: GraphZoomTier;
@@ -305,10 +335,10 @@ export const GRAPH_THEME: GraphTheme = {
zoomTiers: { zoomTiers: {
overview: { overview: {
maxRatio: Number.POSITIVE_INFINITY, maxRatio: Number.POSITIVE_INFINITY,
nodeScale: 0.88, nodeScale: 0.72,
labelThreshold: 0.92, labelThreshold: 0.995,
labelBudget: 28, labelBudget: 4,
edgePriorityThreshold: 0.55, edgePriorityThreshold: 0.72,
arrowPriorityThreshold: Number.POSITIVE_INFINITY, arrowPriorityThreshold: Number.POSITIVE_INFINITY,
edgeSizeScale: 0.62, edgeSizeScale: 0.62,
showBadges: false, showBadges: false,
@@ -317,21 +347,21 @@ export const GRAPH_THEME: GraphTheme = {
}, },
structure: { structure: {
maxRatio: 1.2, maxRatio: 1.2,
nodeScale: 1.02, nodeScale: 0.94,
labelThreshold: 0.82, labelThreshold: 0.93,
labelBudget: 60, labelBudget: 18,
edgePriorityThreshold: 0.3, edgePriorityThreshold: 0.4,
arrowPriorityThreshold: 0.65, arrowPriorityThreshold: 0.75,
edgeSizeScale: 1.05, edgeSizeScale: 0.92,
showBadges: true, showBadges: false,
showCurves: true, showCurves: false,
showContextualArrows: true, showContextualArrows: false,
}, },
inspection: { inspection: {
maxRatio: 0.5, maxRatio: 0.5,
nodeScale: 1.08, nodeScale: 1,
labelThreshold: 0.6, labelThreshold: 0.8,
labelBudget: 120, labelBudget: 40,
edgePriorityThreshold: 0, edgePriorityThreshold: 0,
arrowPriorityThreshold: 0.45, arrowPriorityThreshold: 0.45,
edgeSizeScale: 1.18, edgeSizeScale: 1.18,
@@ -341,7 +371,7 @@ export const GRAPH_THEME: GraphTheme = {
}, },
}, },
labels: { labels: {
forceVisibleStates: ["hovered", "selected", "neighbor", "path"], forceVisibleStates: ["hovered", "selected", "path"],
policies: { policies: {
none: { minZoomTier: "inspection" }, none: { minZoomTier: "inspection" },
priority: { minZoomTier: "overview" }, priority: { minZoomTier: "overview" },
@@ -391,26 +421,26 @@ export const GRAPH_THEME: GraphTheme = {
}, },
nodes: { nodes: {
backgroundScale: 0.52, backgroundScale: 0.52,
mutedAlpha: 0.08, mutedAlpha: 0.16,
strokeHierarchy: { strokeHierarchy: {
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 }, overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 }, structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 }, inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
}, },
states: { states: {
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 }, default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 }, hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 }, selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 }, neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 }, path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 }, inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 }, muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
}, },
variants: { variants: {
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" }, default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" }, temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" }, inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" }, provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" }, selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
}, },
selectedRing: { selectedRing: {
@@ -473,6 +503,35 @@ export const GRAPH_THEME: GraphTheme = {
motion: { motion: {
cameraMs: 380, cameraMs: 380,
}, },
grouped: {
initialLayout: {
innerRadius: 92,
ringSpacing: 138,
minNodeSpacing: 112,
nodePadding: 28,
overlapIterations: 18,
primaryLabelCount: 6,
},
style: {
nodeSizeScale: 0.9,
nodeBorderBoost: 0.42,
fillAlpha: 0.68,
shellAlpha: 0.28,
edgeSizeScale: 0.62,
edgeAlpha: 0.32,
glowAlpha: 0.14,
edgeVisibilityRatio: 0.18,
topIncidentEdges: 2,
},
layout: {
iterations: 18,
gravity: 0.06,
scalingRatio: 18,
edgeWeightInfluence: 0.08,
slowDown: 34,
settleMs: 1500,
},
},
effects: { effects: {
pathPulse: { pathPulse: {
minZoomTier: "structure", minZoomTier: "structure",
@@ -530,7 +589,7 @@ export const GRAPH_THEME: GraphTheme = {
maxGroups: 8, maxGroups: 8,
}, },
diagnostics: { diagnostics: {
enabledInDev: import.meta.env.DEV, enabledInDev: IS_DEV,
}, },
}, },
}; };
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
} }
const selected = context.getSelectedNodeState(); const selected = context.getSelectedNodeState();
const displayState = context.getDisplayState();
if (!selected) { if (!selected) {
return { return {
id: NEIGHBORHOOD_PANEL_ID, id: NEIGHBORHOOD_PANEL_ID,
@@ -82,6 +83,11 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
return left.label.localeCompare(right.label); return left.label.localeCompare(right.label);
}) })
.slice(0, MAX_NEIGHBORS); .slice(0, MAX_NEIGHBORS);
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
const aggregatedEdgeCount = context.displayGraph
.edges()
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
.filter((attrs) => attrs.isAggregated).length;
return { return {
id: NEIGHBORHOOD_PANEL_ID, id: NEIGHBORHOOD_PANEL_ID,
@@ -97,6 +103,34 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
<div style={summaryStyle}> <div style={summaryStyle}>
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph {selected.neighborCount.toLocaleString()} direct neighbors in the full graph
</div> </div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Collapse Neighborhood
</button>
<button
type="button"
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
disabled={!selected.isNeighborhoodCollapsed}
style={controlButtonStyle}
>
Expand Neighborhood
</button>
</div>
{hiddenNeighborCount > 0 ? (
<div style={summaryStyle}>
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
</div>
) : null}
{aggregatedEdgeCount > 0 ? (
<div style={summaryStyle}>
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
</div>
) : null}
{neighbors.length ? ( {neighbors.length ? (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{neighbors.map((neighbor) => ( {neighbors.map((neighbor) => (
@@ -159,6 +193,16 @@ const neighborButtonStyle: CSSProperties = {
cursor: "pointer", cursor: "pointer",
}; };
const controlButtonStyle: CSSProperties = {
padding: "7px 10px",
background: "rgba(255,255,255,0.03)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
color: "#dce7f4",
cursor: "pointer",
fontSize: 12,
};
const swatchStyle: CSSProperties = { const swatchStyle: CSSProperties = {
width: 10, width: 10,
height: 10, height: 10,
@@ -6,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
import type { GraphSceneRuntime } from "../scene"; import type { GraphSceneRuntime } from "../scene";
import type { import type {
GraphAnalyticsSnapshot, GraphAnalyticsSnapshot,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot, GraphDiagnosticsSnapshot,
GraphEffectsState, GraphEffectsState,
GraphEffectToggle, GraphEffectToggle,
@@ -31,6 +32,8 @@ export type GraphPluginActionRequest =
| { type: "focusNode"; nodeId: string } | { type: "focusNode"; nodeId: string }
| { type: "selectNode"; nodeId: string } | { type: "selectNode"; nodeId: string }
| { type: "setViewMode"; viewMode: GraphViewMode } | { type: "setViewMode"; viewMode: GraphViewMode }
| { type: "collapseNeighborhood" }
| { type: "expandNeighborhood" }
| { type: "toggleEffect"; effect: GraphEffectToggle } | { type: "toggleEffect"; effect: GraphEffectToggle }
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean } | { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
| { type: "togglePanel"; panelId: string } | { type: "togglePanel"; panelId: string }
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
getEffectsState: () => GraphEffectsState; getEffectsState: () => GraphEffectsState;
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null; getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null; getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
getDisplayState: () => GraphDisplayStateSnapshot;
isPanelOpen: (panelId: string) => boolean; isPanelOpen: (panelId: string) => boolean;
dispatchAction: (action: GraphPluginActionRequest) => void; dispatchAction: (action: GraphPluginActionRequest) => void;
} }
@@ -5,6 +5,8 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
import type { import type {
GraphAnalyticsSnapshot, GraphAnalyticsSnapshot,
GraphCameraState, GraphCameraState,
GraphDisplayMeta,
GraphDisplayStateSnapshot,
GraphDiagnosticsSnapshot, GraphDiagnosticsSnapshot,
GraphEffectsState, GraphEffectsState,
GraphInteractionState, GraphInteractionState,
@@ -22,6 +24,8 @@ export interface GraphSceneRuntime {
scene: unknown; scene: unknown;
graph: GraphSceneGraph; graph: GraphSceneGraph;
displayGraph: GraphSceneGraph; displayGraph: GraphSceneGraph;
graphVersion: number;
layoutMode?: GraphDisplayMeta["layoutMode"];
requestRender: () => void; requestRender: () => void;
getCameraState: () => GraphCameraState | null; getCameraState: () => GraphCameraState | null;
} }
@@ -37,7 +41,13 @@ export interface GraphSceneEventMap {
} }
export interface GraphSceneProps extends GraphSceneEventMap { export interface GraphSceneProps extends GraphSceneEventMap {
graphVersion: number;
graphReady: boolean;
displayGraph: GraphSceneGraph;
displayMeta: GraphDisplayMeta;
displayState?: GraphDisplayStateSnapshot;
selectedNodeId: string; selectedNodeId: string;
focusedNodeId: string;
selectedEdgeId: string; selectedEdgeId: string;
activePath?: string[]; activePath?: string[];
activePathEdgeIds?: string[]; activePathEdgeIds?: string[];
@@ -56,6 +66,8 @@ export interface GraphSceneProps extends GraphSceneEventMap {
export interface GraphSceneHandle { export interface GraphSceneHandle {
fitView: () => void; fitView: () => void;
focusNode: (nodeId: string) => void; focusNode: (nodeId: string) => void;
zoomIn: () => void;
zoomOut: () => void;
getRuntime: () => GraphSceneRuntime | null; getRuntime: () => GraphSceneRuntime | null;
setLayoutRunning?: (running: boolean) => void; setLayoutRunning?: (running: boolean) => void;
} }
@@ -1,4 +1,4 @@
export type GraphViewMode = "focused" | "full"; export type GraphViewMode = "focused" | "full" | "grouped";
export type GraphLayoutSource = "provided" | "carried" | "runtime"; export type GraphLayoutSource = "provided" | "carried" | "runtime";
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed"; export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
export type GraphLoadPhase = export type GraphLoadPhase =
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
export type GraphLoadProgressKind = "determinate" | "indeterminate"; export type GraphLoadProgressKind = "determinate" | "indeterminate";
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted"; export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted"; export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
export interface GraphCameraState { export interface GraphCameraState {
x: number; x: number;
@@ -31,6 +32,28 @@ export interface GraphInteractionState {
isLayoutRunning: boolean; isLayoutRunning: boolean;
} }
export interface GraphDisplayStateSnapshot {
aggregationEnabled: boolean;
groupedViewAvailable: boolean;
groupedViewReason: string | null;
selectedRootNodeId: string | null;
selectedVisibleNeighborIds: string[];
selectedCollapsedNeighborIds: string[];
selectedNodeKind: GraphSelectedNodeKind;
canActivateFocused: boolean;
resolvedFocusedNodeId: string | null;
focusedUnavailableReason: string | null;
}
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
export interface GraphDisplayMeta {
layoutMode: GraphDisplayLayoutMode;
positionSource: "store" | "display";
tracksStoreNodePositions: boolean;
hasSyntheticNodes: boolean;
}
export type GraphEffectToggle = export type GraphEffectToggle =
| "pathPulseEnabled" | "pathPulseEnabled"
| "pathFlowEnabled" | "pathFlowEnabled"
@@ -245,6 +268,10 @@ export interface GraphSelectedNodeState {
valid_until?: string | null; valid_until?: string | null;
properties: Record<string, unknown>; properties: Record<string, unknown>;
neighborCount: number; neighborCount: number;
visibleNeighborCount: number;
collapsedNeighborCount: number;
isNeighborhoodCollapsed: boolean;
canCollapseNeighborhood: boolean;
} }
export interface GraphSelectedEdgeState { export interface GraphSelectedEdgeState {
@@ -260,6 +287,12 @@ export interface GraphSelectedEdgeState {
provenanceCount: number; provenanceCount: number;
familySize: number; familySize: number;
siblingCount: number; siblingCount: number;
isAggregated: boolean;
aggregateCount: number;
rawEdgeIds: string[];
bundleKind: "parallel" | "bidirectional" | "community" | null;
dominantEdgeType: string | null;
representativeWeight: number;
} }
export interface GraphStageHandle { export interface GraphStageHandle {
@@ -0,0 +1,259 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
batchMergeEdges,
batchMergeNodes,
clearGraph,
} from "../src/store/graphStore.ts";
import {
checkGroupedViewAvailability,
resolveDisplayGraph,
resolveGroupedDisplayNodeId,
resolveGroupedDisplayStateSnapshot,
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
function addNode(id: string, semanticGroup = "entity") {
batchMergeNodes([
{
id,
attributes: {
label: id,
content: id,
x: 0,
y: 0,
size: 8,
color: "#63E6FF",
baseColor: "#63E6FF",
nodeType: semanticGroup,
semanticGroup,
properties: {},
},
},
]);
}
function addEdge(id: string, source: string, target: string, weight = 1) {
batchMergeEdges([
{
id,
source,
target,
attributes: {
edgeType: "related_to",
weight,
properties: {},
},
},
]);
}
test.beforeEach(() => {
clearGraph();
});
test.after(() => {
clearGraph();
});
test("resolveDisplayGraph bundles parallel edges in full view", () => {
addNode("a");
addNode("b");
addEdge("e1", "a", "b", 1);
addEdge("e2", "a", "b", 2);
const { graph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: true });
assert.equal(graph.size, 1);
const edgeId = graph.edges()[0];
const attrs = graph.getEdgeAttributes(edgeId) as {
isAggregated?: boolean;
aggregateCount?: number;
rawEdgeIds?: string[];
bundleKind?: string;
};
assert.equal(attrs.isAggregated, true);
assert.equal(attrs.aggregateCount, 2);
assert.deepEqual(new Set(attrs.rawEdgeIds ?? []), new Set(["e1", "e2"]));
assert.equal(attrs.bundleKind, "parallel");
});
test("resolveDisplayGraph collapse keeps path neighbor visible", () => {
addNode("center");
for (let index = 0; index < 10; index += 1) {
const neighbor = `n${index}`;
addNode(neighbor);
addEdge(`edge-${index}`, "center", neighbor, 1);
}
const { state } = resolveDisplayGraph("center", ["center", "n9"], [], "full", {
aggregationEnabled: false,
collapsedNeighborhoodNodeIds: ["center"],
});
assert.equal(state.selectedRootNodeId, "center");
assert.equal(state.selectedVisibleNeighborIds.includes("n9"), true);
assert.equal(state.selectedVisibleNeighborIds.length, 9);
assert.equal(state.selectedCollapsedNeighborIds.length, 1);
});
test("resolveDisplayGraph grouped view emits community nodes and edges", () => {
const left = ["a1", "a2", "a3", "a4"];
const right = ["b1", "b2", "b3", "b4"];
[...left, ...right].forEach((nodeId, index) => {
addNode(nodeId, index < left.length ? "left" : "right");
});
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) {
addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
}
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) {
addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
}
}
}
addEdge("bridge-1", "a1", "b1", 0.1);
addEdge("bridge-2", "a2", "b2", 0.1);
const { graph, state } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
assert.equal(state.groupedViewAvailable, true);
const communityNodes = graph.nodes().filter((nodeId) => nodeId.startsWith("__community__"));
assert.ok(communityNodes.length >= 2);
const hasCommunityEdge = graph
.edges()
.map((edgeId) => graph.getEdgeAttributes(edgeId) as { bundleKind?: string; isAggregated?: boolean; aggregateCount?: number })
.some((attrs) => attrs.bundleKind === "community" && attrs.isAggregated === true && Number(attrs.aggregateCount ?? 0) > 0);
assert.equal(hasCommunityEdge, true);
});
// ── resolveGroupedDisplayNodeId ──────────────────────────────────────────────
test("resolveGroupedDisplayNodeId returns null for empty nodeId", () => {
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(resolveGroupedDisplayNodeId(displayGraph, ""), null);
});
test("resolveGroupedDisplayNodeId returns nodeId when it exists directly in display graph", () => {
addNode("x");
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
assert.equal(resolveGroupedDisplayNodeId(displayGraph, "x"), "x");
});
test("resolveGroupedDisplayNodeId resolves base node to its community node", () => {
const left = ["a1", "a2", "a3", "a4"];
const right = ["b1", "b2", "b3", "b4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`l-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`r-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-1", "a1", "b1", 0.1);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
const communityNodes = displayGraph.nodes().filter((n) => n.startsWith("__community__"));
assert.ok(communityNodes.length >= 2, "expected community nodes");
const resolved = resolveGroupedDisplayNodeId(displayGraph, "a1");
assert.ok(resolved !== null, "should resolve a1 to a community node");
assert.ok(resolved!.startsWith("__community__"), "resolved id should be a community node");
});
// ── resolveGroupedDisplayStateSnapshot ──────────────────────────────────────
test("resolveGroupedDisplayStateSnapshot returns none-kind when no node selected", () => {
addNode("p");
addNode("q");
addEdge("e1", "p", "q");
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "full", { aggregationEnabled: false });
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "", {
groupedViewAvailable: true,
groupedViewReason: null,
});
assert.equal(state.selectedNodeKind, "none");
assert.equal(state.selectedRootNodeId, null);
});
test("resolveGroupedDisplayStateSnapshot maps selected base node to community in grouped graph", () => {
const left = ["c1", "c2", "c3", "c4"];
const right = ["d1", "d2", "d3", "d4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`lc-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`rc-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-c1", "c1", "d1", 0.1);
addEdge("bridge-c2", "c2", "d2", 0.1);
const { graph: displayGraph } = resolveDisplayGraph("", [], [], "grouped", { aggregationEnabled: true });
const state = resolveGroupedDisplayStateSnapshot(displayGraph, "c1", {
groupedViewAvailable: true,
groupedViewReason: null,
selectedNodeKind: "grouped",
});
assert.ok(state.selectedRootNodeId !== null, "should resolve to a community node");
assert.ok(state.selectedRootNodeId!.startsWith("__community__"), "root should be a community node");
assert.equal(state.groupedViewAvailable, true);
});
// ── checkGroupedViewAvailability ─────────────────────────────────────────────
test("checkGroupedViewAvailability returns unavailable on empty graph", () => {
const result = checkGroupedViewAvailability();
assert.equal(result.available, false);
assert.ok(typeof result.reason === "string" && result.reason.length > 0);
});
test("checkGroupedViewAvailability returns available when communities exist", () => {
const left = ["e1", "e2", "e3", "e4"];
const right = ["f1", "f2", "f3", "f4"];
[...left, ...right].forEach((nodeId, index) => addNode(nodeId, index < left.length ? "left" : "right"));
let edgeIndex = 0;
for (let i = 0; i < left.length; i += 1) {
for (let j = 0; j < left.length; j += 1) {
if (i !== j) addEdge(`le-${edgeIndex++}`, left[i], left[j], 3);
}
}
for (let i = 0; i < right.length; i += 1) {
for (let j = 0; j < right.length; j += 1) {
if (i !== j) addEdge(`re-${edgeIndex++}`, right[i], right[j], 3);
}
}
addEdge("bridge-e1", "e1", "f1", 0.1);
const result = checkGroupedViewAvailability();
assert.equal(result.available, true);
assert.equal(result.reason, null);
});
+2 -1
View File
@@ -91,7 +91,8 @@ claude --plugin-dir ./plugins
Or inside a session: Or inside a session:
```bash ```bash
/plugin install ./plugins /plugin marketplace add ./plugins
/plugin install semantica@semantica-local
``` ```
Verify: Verify:
+4
View File
@@ -1,5 +1,9 @@
{ {
"name": "semantica-local", "name": "semantica-local",
"owner": {
"name": "Hawksight AI",
"url": "https://github.com/Hawksight-AI/semantica"
},
"plugins": [ "plugins": [
{ {
"name": "semantica", "name": "semantica",
+1 -2
View File
@@ -25,6 +25,5 @@
"mcp" "mcp"
], ],
"skills": "./skills", "skills": "./skills",
"agents": "./agents", "agents": "./agents"
"hooks": "./hooks/hooks.json"
} }
+1 -1
View File
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
llm-gemini = ["google-genai>=0.1.0"] llm-gemini = ["google-genai>=0.1.0"]
llm-anthropic = ["anthropic>=0.18.0"] llm-anthropic = ["anthropic>=0.18.0"]
llm-ollama = ["ollama>=0.1.0"] llm-ollama = ["ollama>=0.1.0"]
llm-deepseek = ["deepseek>=0.1.0"] llm-deepseek = ["openai>=1.0.0"]
llm-litellm = ["litellm>=1.0.0"] llm-litellm = ["litellm>=1.0.0"]
llm-instructor = ["instructor>=1.0.0"] llm-instructor = ["instructor>=1.0.0"]
+205 -5
View File
@@ -74,6 +74,7 @@ Production Use Cases:
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger from ..utils.logging import get_logger
from .agent_memory import AgentMemory from .agent_memory import AgentMemory
from .context_retriever import ContextRetriever, RetrievedContext from .context_retriever import ContextRetriever, RetrievedContext
@@ -507,6 +508,10 @@ class AgentContext:
include_relationships: bool = False, include_relationships: bool = False,
expand_graph: bool = True, expand_graph: bool = True,
deduplicate: 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, **kwargs,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
@@ -560,17 +565,33 @@ class AgentContext:
**kwargs, **kwargs,
) )
# Convert RetrievedContext to dicts # Convert RetrievedContext to dicts
return [ result_dicts = [
self._context_to_dict(r, include_entities, include_relationships) self._context_to_dict(r, include_entities, include_relationships)
for r in results 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: else:
# Simple RAG: Use AgentMemory (vector + memory) # Simple RAG: Use AgentMemory (vector + memory)
results = self._memory.retrieve( results = self._memory.retrieve(
query, max_results=max_results, min_score=min_score, **kwargs query, max_results=max_results, min_score=min_score, **kwargs
) )
# Convert to dicts # 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( def query_with_reasoning(
self, self,
@@ -814,6 +835,77 @@ class AgentContext:
return result 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]: def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
"""Convert memory result to dict.""" """Convert memory result to dict."""
return { return {
@@ -2228,7 +2320,10 @@ class AgentContext:
category: Optional[str] = None, category: Optional[str] = None,
limit: int = 10, limit: int = 10,
use_kg_features: bool = True, 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]: ) -> List[Decision]:
""" """
Find precedents using advanced KG and vector store features. Find precedents using advanced KG and vector store features.
@@ -2248,7 +2343,7 @@ class AgentContext:
try: try:
if hasattr(self._decision_query, 'find_precedents_hybrid'): if hasattr(self._decision_query, 'find_precedents_hybrid'):
return self._decision_query.find_precedents_hybrid( precedents = self._decision_query.find_precedents_hybrid(
scenario=scenario, scenario=scenario,
category=category, category=category,
limit=limit, limit=limit,
@@ -2257,11 +2352,116 @@ class AgentContext:
) )
else: else:
# Fallback to basic method # 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: except Exception as e:
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})") self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
return [] 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]: def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
""" """
Analyze decision influence using advanced graph algorithms. 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 collections import deque
from ..graph_store import GraphStore from ..graph_store import GraphStore
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger from ..utils.logging import get_logger
from .decision_models import Decision from .decision_models import Decision
@@ -677,3 +678,102 @@ class CausalChainAnalyzer:
else: else:
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0)) decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
return decisions 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.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker from ..utils.progress_tracker import get_progress_tracker
from ..utils.helpers import classify_path_distance
from .entity_linker import EntityLinker from .entity_linker import EntityLinker
# Optional imports for advanced features # Optional imports for advanced features
@@ -130,6 +131,13 @@ except ImportError:
KG_AVAILABLE = False 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]: def _parse_iso_dt(value: str) -> Optional[datetime]:
"""Parse an ISO datetime string into a tz-naive UTC datetime. """Parse an ISO datetime string into a tz-naive UTC datetime.
@@ -739,6 +747,7 @@ class ContextGraph:
min_weight: float = 0.0, min_weight: float = 0.0,
skip: int = 0, skip: int = 0,
limit: Optional[int] = None, limit: Optional[int] = None,
include_distance_metadata: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get neighbors of a node. Get neighbors of a node.
@@ -762,11 +771,11 @@ class ContextGraph:
neighbors: List[Dict[str, Any]] = [] neighbors: List[Dict[str, Any]] = []
visited = {node_id} 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 rel_filter = set(relationship_types) if relationship_types else None
while queue: while queue:
current_id, current_hop = queue.popleft() current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
if current_hop >= hops: if current_hop >= hops:
continue continue
@@ -780,26 +789,59 @@ class ContextGraph:
if neighbor_id in visited: if neighbor_id in visited:
continue continue
visited.add(neighbor_id) 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) node = self.nodes.get(neighbor_id)
if not node: if not node:
continue continue
neighbors.append( entry: Dict[str, Any] = {
{ "id": node.node_id,
"id": node.node_id, "type": node.node_type,
"type": node.node_type, "content": node.content,
"content": node.content, "relationship": edge.edge_type,
"relationship": edge.edge_type, "weight": edge.weight,
"weight": edge.weight, "hop": next_hop,
"hop": current_hop + 1, }
} 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: if limit is not None:
return neighbors[skip: skip + limit] return neighbors[skip: skip + limit]
return neighbors[skip:] 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( def query(
self, query: str, skip: int = 0, limit: Optional[int] = None self, query: str, skip: int = 0, limit: Optional[int] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
@@ -1187,6 +1229,90 @@ class ContextGraph:
other_graph, _, target_node_id = self._linked_graphs[link_id] other_graph, _, target_node_id = self._linked_graphs[link_id]
return other_graph, target_node_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: def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
""" """
Reconnect cross-graph links after a :meth:`load_from_file` call. Reconnect cross-graph links after a :meth:`load_from_file` call.
@@ -2552,13 +2678,14 @@ class ContextGraph:
# Calculate influence scores # Calculate influence scores
influence_scores = {} influence_scores = {}
for influenced_id in direct_influence | indirect_influence: for influenced_id in direct_influence | indirect_influence:
score = self._calculate_decision_influence_score(decision_id, influenced_id) influence_scores[influenced_id] = self._calculate_decision_influence_score(
influence_scores[influenced_id] = score decision_id, influenced_id
)
# Sort by influence score # Sort by influence score
sorted_influence = sorted( sorted_influence = sorted(
influence_scores.items(), influence_scores.items(),
key=lambda x: x[1], key=lambda x: x[1].get("score", 0.0),
reverse=True reverse=True
) )
@@ -2576,11 +2703,22 @@ class ContextGraph:
"direct_influence": [_enrich(did) for did in direct_influence], "direct_influence": [_enrich(did) for did in direct_influence],
"indirect_influence": [_enrich(did) for did in indirect_influence], "indirect_influence": [_enrich(did) for did in indirect_influence],
"influence_scores": [ "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), "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]: def get_decision_insights(self) -> Dict[str, Any]:
@@ -2677,15 +2815,17 @@ class ContextGraph:
for cause_id in potential_causes: for cause_id in potential_causes:
cause_dec = self._decisions.get(cause_id, {}) cause_dec = self._decisions.get(cause_id, {})
edge_weight = float(cause_dec.get("confidence", 1.0))
hop = { hop = {
"from": cause_id, "from": cause_id,
"from_scenario": cause_dec.get("scenario", ""), "from_scenario": cause_dec.get("scenario", ""),
"to": current_id, "to": current_id,
"to_scenario": current_decision.get("scenario", ""), "to_scenario": current_decision.get("scenario", ""),
"type": "influences", "type": "influences",
"edge_weight": edge_weight,
} }
cause_path = path + [hop] 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(cause_id, depth + 1, cause_path)
trace_recursive(decision_id, 0, []) trace_recursive(decision_id, 0, [])
@@ -2942,11 +3082,49 @@ class ContextGraph:
self.logger.warning(f"Indirect influence analysis failed: {e}") self.logger.warning(f"Indirect influence analysis failed: {e}")
return set() 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.""" """Calculate influence score between two decisions."""
try: try:
if not hasattr(self, '_decisions'): 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] source_decision = self._decisions[source_id]
target_decision = self._decisions[target_id] target_decision = self._decisions[target_id]
@@ -2965,11 +3143,16 @@ class ContextGraph:
# Combined score # Combined score
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_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: except Exception as e:
self.logger.warning(f"Influence score calculation failed: {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]: def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
"""Get temporal analysis of decisions.""" """Get temporal analysis of decisions."""
+16
View File
@@ -19,7 +19,12 @@ from .ws import ConnectionManager
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None: def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
previous_callback = getattr(session.graph, "mutation_callback", None)
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None: def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
session.handle_graph_mutation(event_type, entity_id, payload)
if callable(previous_callback):
previous_callback(event_type, entity_id, payload)
loop = getattr(app.state, "event_loop", None) loop = getattr(app.state, "event_loop", None)
manager = getattr(app.state, "ws_manager", None) manager = getattr(app.state, "ws_manager", None)
if loop is None or manager is None or loop.is_closed(): if loop is None or manager is None or loop.is_closed():
@@ -150,6 +155,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
"status": "active", "status": "active",
} }
@app.get("/", include_in_schema=False)
async def root():
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
if index_path.is_file():
return FileResponse(index_path)
return HTMLResponse(
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
'<title>Semantica Knowledge Explorer</title></head>'
'<body><div id="root"></div></body></html>'
)
static_dir = Path(__file__).resolve().parent.parent / "static" static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
assets_dir = static_dir / "assets" assets_dir = static_dir / "assets"
+15 -1
View File
@@ -8,7 +8,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..dependencies import get_session from ..dependencies import get_session
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
from ..session import GraphSession from ..session import GraphSession
router = APIRouter(prefix="/api/decisions", tags=["Decisions"]) 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]] 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) @router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
async def check_compliance( async def check_compliance(
decision_id: str, decision_id: str,
+5 -3
View File
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
continue continue
source, target = args source, target = args
if session.get_node(source) is None: if session.get_node(source) is None:
session.graph.add_node(source, "entity", content=source) session.add_node(source, "entity", content=source)
if session.get_node(target) is None: if session.get_node(target) is None:
session.graph.add_node(target, "entity", content=target) session.add_node(target, "entity", content=target)
edge_type = body.inferred_edge_type or predicate edge_type = body.inferred_edge_type or predicate
session.graph.add_edge( session.add_edge(
source, source,
target, target,
edge_type=edge_type, edge_type=edge_type,
@@ -354,4 +354,6 @@ async def merge_nodes(
return removed, edges_updated return removed, edges_updated
removed_ids, edges_updated = await asyncio.to_thread(_do_merge) removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
if removed_ids:
await asyncio.to_thread(session.rebuild_search_index)
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated) return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
+56 -1
View File
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import Response from fastapi.responses import Response
from ..dependencies import get_session from ..dependencies import get_session
from ..schemas import ExportRequest, ImportResponse from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
from ..session import GraphSession from ..session import GraphSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -236,3 +236,58 @@ async def export_graph(
media_type=media_type, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'}, 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"'},
)
+303 -6
View File
@@ -3,14 +3,20 @@ Graph routes for explorer node, edge, path, and search APIs.
""" """
import asyncio import asyncio
import logging
import time
from enum import Enum 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 fastapi import APIRouter, Depends, HTTPException, Query
from ...utils.helpers import classify_path_distance from ...utils.helpers import classify_path_distance
from ..dependencies import get_session from ..dependencies import get_session
from ..schemas import ( from ..schemas import (
DistanceMatrixRequest,
DistanceMatrixResponse,
EdgeListResponse, EdgeListResponse,
EdgeResponse, EdgeResponse,
GraphStatsResponse, GraphStatsResponse,
@@ -21,12 +27,45 @@ from ..schemas import (
SearchRequest, SearchRequest,
SearchResultItem, SearchResultItem,
SearchResultResponse, SearchResultResponse,
SemanticNeighborItem,
SemanticNeighborhoodResponse,
) )
from ..session import GraphSession from ..session import GraphSession
router = APIRouter(prefix="/api/graph", tags=["Graph"]) 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]]: def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
if not raw_bbox: if not raw_bbox:
return None return None
@@ -175,6 +214,93 @@ async def find_path(
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes) edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
hop_count = len(path_nodes) - 1 if path_nodes else 0 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, node_id, 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, node_id, 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( return PathResponse(
source=node_id, source=node_id,
target=target, target=target,
@@ -184,7 +310,13 @@ async def find_path(
total_weight=total_weight, total_weight=total_weight,
directed=directed, directed=directed,
hop_count=hop_count, 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,
) )
@@ -194,13 +326,178 @@ async def search_nodes(
session: GraphSession = Depends(get_session), session: GraphSession = Depends(get_session),
): ):
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters) 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)) # FR-7 — compute hop distances from anchor when requested
for result in results 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) 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,
)
@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),
):
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")
neighbors: List[SemanticNeighborItem] = []
if session.similarity is not None:
graph_dict = await asyncio.to_thread(session.build_graph_dict)
try:
similar = await asyncio.to_thread(
session.similarity.find_most_similar,
graph_dict, node_id, top_k=top_k * 2
)
# 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=nid,
type=neighbor_node.get("type", ""),
content=neighbor_node.get("content", ""),
similarity=float(sim_score),
)
)
if len(neighbors) >= top_k:
break
except Exception as exc:
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
return SemanticNeighborhoodResponse(
anchor_node=node_id,
neighbors=neighbors,
total=len(neighbors),
)
@router.get("/stats", response_model=GraphStatsResponse) @router.get("/stats", response_model=GraphStatsResponse)
async def graph_stats( async def graph_stats(
session: GraphSession = Depends(get_session), session: GraphSession = Depends(get_session),
+34 -27
View File
@@ -1,4 +1,4 @@
""" """
Provenance routes for lineage visualization and exportable reports. Provenance routes for lineage visualization and exportable reports.
""" """
@@ -9,33 +9,13 @@ from typing import Any, Dict, List, Optional
import networkx as nx import networkx as nx
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import PlainTextResponse, Response from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel
from ..dependencies import get_session from ..dependencies import get_session
from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
from ..session import GraphSession from ..session import GraphSession
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"]) router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
class ProvenanceNode(BaseModel):
id: str
label: str
prov_type: str
parent_id: str
class ProvenanceEdge(BaseModel):
id: str
source: str
target: str
label: str
class ProvenanceResponse(BaseModel):
nodes: List[ProvenanceNode]
edges: List[ProvenanceEdge]
_AGENT_TYPES = {"person", "organization", "system", "agent"} _AGENT_TYPES = {"person", "organization", "system", "agent"}
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"} _ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
@@ -67,7 +47,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
if edge.source_id in hop_nodes or edge.target_id in hop_nodes: if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type) graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False) subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
provenance_nodes: List[Dict[str, Any]] = [] provenance_nodes: List[Dict[str, Any]] = []
for graph_node_id in subgraph.nodes(): for graph_node_id in subgraph.nodes():
node = session.graph.nodes.get(graph_node_id) node = session.graph.nodes.get(graph_node_id)
@@ -85,12 +65,19 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
provenance_edges: List[Dict[str, Any]] = [] provenance_edges: List[Dict[str, Any]] = []
for source, target, data in subgraph.edges(data=True): for source, target, data in subgraph.edges(data=True):
if target == node_id:
direction = "upstream"
elif source == node_id:
direction = "downstream"
else:
direction = "lateral"
provenance_edges.append( provenance_edges.append(
{ {
"id": f"{source}-{target}", "id": f"{source}-{target}",
"source": source, "source": source,
"target": target, "target": target,
"label": data.get("label", "related_to"), "label": data.get("label", "related_to"),
"direction": direction,
} }
) )
@@ -104,7 +91,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
"node_id": node_id, "node_id": node_id,
"label": node.get("content", node_id) if node else node_id, "label": node.get("content", node_id) if node else node_id,
"type": node.get("type", "entity") if node else "entity", "type": node.get("type", "entity") if node else "entity",
"properties": node.get("properties", {}) if node else {}, "properties": node.get("metadata", node.get("properties", {})) if node else {},
"lineage": provenance, "lineage": provenance,
} }
@@ -129,9 +116,29 @@ def _render_markdown(report: Dict[str, Any]) -> str:
for node in report.get("lineage", {}).get("nodes", []): for node in report.get("lineage", {}).get("nodes", []):
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}") lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
lines.extend(["", "## Lineage Edges"]) edges = report.get("lineage", {}).get("edges", [])
for edge in report.get("lineage", {}).get("edges", []): grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`") for edge in edges:
direction = edge.get("direction", "lateral")
if direction not in grouped_edges:
direction = "lateral"
grouped_edges[direction].append(edge)
if grouped_edges["upstream"]:
lines.extend(["", "## Upstream"])
for edge in grouped_edges["upstream"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
if grouped_edges["downstream"]:
lines.extend(["", "## Downstream"])
for edge in grouped_edges["downstream"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
if grouped_edges["lateral"]:
lines.extend(["", "## Lateral"])
for edge in grouped_edges["lateral"]:
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
return "\n".join(lines) return "\n".join(lines)
+127 -2
View File
@@ -5,14 +5,20 @@ Temporal routes for snapshots, diffs, and pattern detection.
import asyncio import asyncio
import logging import logging
import re import re
from datetime import datetime, timezone, UTC from datetime import datetime, timedelta, timezone, UTC
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from ..dependencies import get_session from ..dependencies import get_session
from ..schemas import TemporalDiffResponse, TemporalPatternResponse from ..schemas import (
DistanceEvent,
DistanceHistoryResponse,
DistanceSnapshot,
TemporalDiffResponse,
TemporalPatternResponse,
)
from ..session import GraphSession from ..session import GraphSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,3 +126,122 @@ async def temporal_bounds(
): ):
bounds = await asyncio.to_thread(session.get_temporal_bounds) bounds = await asyncio.to_thread(session.get_temporal_bounds)
return TemporalBoundsResponse(**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,
)
+125 -1
View File
@@ -2,7 +2,8 @@
Shared Pydantic schemas for the Semantica Knowledge Explorer API. 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 from pydantic import BaseModel, Field
@@ -70,6 +71,13 @@ class PathResponse(BaseModel):
directed: bool = True directed: bool = True
hop_count: int = 0 hop_count: int = 0
distance_band: str = "direct" 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): class GraphStatsResponse(BaseModel):
@@ -84,11 +92,19 @@ class SearchRequest(BaseModel):
query: str query: str
filters: Dict[str, Any] = Field(default_factory=dict) filters: Dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=20, ge=1, le=200) 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): class SearchResultItem(BaseModel):
node: NodeResponse node: NodeResponse
score: float = 0.0 score: float = 0.0
# FR-7 distance metadata
hop_distance: Optional[int] = None
semantic_similarity: Optional[float] = None
class SearchResultResponse(BaseModel): class SearchResultResponse(BaseModel):
@@ -288,3 +304,111 @@ class MergeResponse(BaseModel):
merged_into: str merged_into: str
removed_ids: List[str] removed_ids: List[str]
edges_updated: int edges_updated: int
class ProvenanceNode(BaseModel):
id: str
label: str
prov_type: str
parent_id: Optional[str] = None
class ProvenanceEdge(BaseModel):
id: str
source: str
target: str
label: str
direction: str
class ProvenanceResponse(BaseModel):
nodes: List[ProvenanceNode]
edges: List[ProvenanceEdge]
# ---------------------------------------------------------------------------
# FR-6 — Distance Matrix API
# ---------------------------------------------------------------------------
class DistanceMatrixRequest(BaseModel):
node_ids: List[str]
metric: Literal["hops", "weighted", "semantic"] = "hops"
class DistanceMatrixResponse(BaseModel):
nodes: List[str]
metric: str
matrix: List[List[Optional[float]]]
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
computation_time_ms: float
# ---------------------------------------------------------------------------
# FR-3 backend — Semantic Neighborhood
# ---------------------------------------------------------------------------
class SemanticNeighborItem(BaseModel):
id: str
type: str
content: str = ""
similarity: float
hop_distance: Optional[int] = None
class SemanticNeighborhoodResponse(BaseModel):
anchor_node: str
neighbors: List[SemanticNeighborItem]
total: int
# ---------------------------------------------------------------------------
# FR-8 — Causal Distance Report
# ---------------------------------------------------------------------------
class CausalDistanceReport(BaseModel):
source_id: str
target_id: str
causal_path: List[str]
causal_hop_count: int
intermediate_decisions: List[str]
confidence_decay: float
weakest_link: Optional[Dict[str, Any]] = None
interpretation: str
# ---------------------------------------------------------------------------
# FR-9 — Temporal Distance Alerts
# ---------------------------------------------------------------------------
class DistanceSnapshot(BaseModel):
timestamp: datetime
hop_count: Optional[int] = None
distance_band: str
class DistanceEvent(BaseModel):
timestamp: datetime
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
hop_count_before: Optional[int] = None
hop_count_after: Optional[int] = None
description: str
class DistanceHistoryResponse(BaseModel):
source_id: str
target_id: str
metric: str
history: List[DistanceSnapshot]
events: List[DistanceEvent]
# ---------------------------------------------------------------------------
# FR-10 — Distance-Enriched Export
# ---------------------------------------------------------------------------
class DistanceExportRequest(BaseModel):
format: Literal["csv", "jsonl"] = "csv"
node_subset: Optional[List[str]] = None
include: List[str] = Field(
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
)
+398
View File
@@ -0,0 +1,398 @@
"""
Explorer-local in-memory node search index.
"""
from __future__ import annotations
import bisect
import heapq
import re
from collections import OrderedDict, defaultdict
from dataclasses import dataclass
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple
_TOKEN_RE = re.compile(r"[a-z0-9]+")
_WHITESPACE_RE = re.compile(r"\s+")
_CURATED_ALIAS_KEYS = (
"label",
"name",
"title",
"pref_label",
"preferred_label",
"prefLabel",
"aliases",
"alias",
"synonyms",
"synonym",
"symbol",
"display_name",
"displayName",
"text",
"content",
)
def _normalize_text(value: Any) -> str:
if value is None:
return ""
text = str(value).strip().lower()
if not text:
return ""
return _WHITESPACE_RE.sub(" ", text)
def _tokenize(text: str) -> Tuple[str, ...]:
if not text:
return ()
return tuple(dict.fromkeys(_TOKEN_RE.findall(text)))
def _collect_text_fragments(value: Any, fragments: List[str], *, limit: int = 64) -> None:
if value is None or len(fragments) >= limit:
return
if isinstance(value, dict):
for nested in value.values():
_collect_text_fragments(nested, fragments, limit=limit)
if len(fragments) >= limit:
return
return
if isinstance(value, (list, tuple, set)):
for nested in value:
_collect_text_fragments(nested, fragments, limit=limit)
if len(fragments) >= limit:
return
return
normalized = _normalize_text(value)
if normalized:
fragments.append(normalized)
def _coerce_float(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass(frozen=True)
class IndexedNodeDocument:
node_id: str
normalized_id: str
node_type: str
exact_terms: frozenset[str]
tokens: frozenset[str]
primary_text: str
secondary_text: str
confidence: Optional[float]
tags: Tuple[str, ...]
class GraphSearchIndex:
def __init__(
self,
*,
cache_size: int = 128,
prefix_min_length: int = 2,
prefix_max_length: int = 12,
secondary_scan_limit: int = 12000,
) -> None:
self.cache_size = cache_size
self.prefix_min_length = prefix_min_length
self.prefix_max_length = prefix_max_length
self.secondary_scan_limit = secondary_scan_limit
self._documents: Dict[str, IndexedNodeDocument] = {}
self._exact_index: DefaultDict[str, set[str]] = defaultdict(set)
self._token_index: DefaultDict[str, set[str]] = defaultdict(set)
self._prefix_index: DefaultDict[str, set[str]] = defaultdict(set)
self._ordered_node_ids: List[str] = []
self._cache: OrderedDict[Tuple[Any, ...], List[Tuple[str, float]]] = OrderedDict()
def rebuild(self, nodes: Iterable[Dict[str, Any]]) -> None:
self._documents.clear()
self._exact_index.clear()
self._token_index.clear()
self._prefix_index.clear()
self._ordered_node_ids = []
self.clear_cache()
for node in nodes:
self.upsert(node, clear_cache=False)
self._ordered_node_ids.sort()
def clear_cache(self) -> None:
self._cache.clear()
def remove(self, node_id: str, *, clear_cache: bool = True) -> None:
existing = self._documents.pop(node_id, None)
if existing is None:
return
for term in existing.exact_terms:
bucket = self._exact_index.get(term)
if bucket is None:
continue
bucket.discard(node_id)
if not bucket:
self._exact_index.pop(term, None)
for token in existing.tokens:
bucket = self._token_index.get(token)
if bucket is None:
continue
bucket.discard(node_id)
if not bucket:
self._token_index.pop(token, None)
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
prefix = token[:length]
prefix_bucket = self._prefix_index.get(prefix)
if prefix_bucket is None:
continue
prefix_bucket.discard(node_id)
if not prefix_bucket:
self._prefix_index.pop(prefix, None)
pos = bisect.bisect_left(self._ordered_node_ids, node_id)
if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
self._ordered_node_ids.pop(pos)
if clear_cache:
self.clear_cache()
def upsert(self, node: Dict[str, Any], *, clear_cache: bool = True) -> None:
node_id = str(node.get("id", "")).strip()
if not node_id:
return
self.remove(node_id, clear_cache=False)
document = self._build_document(node)
self._documents[node_id] = document
for term in document.exact_terms:
self._exact_index[term].add(node_id)
for token in document.tokens:
self._token_index[token].add(node_id)
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
self._prefix_index[token[:length]].add(node_id)
bisect.insort(self._ordered_node_ids, node_id)
if clear_cache:
self.clear_cache()
def search(
self,
query: str,
*,
limit: int = 20,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Tuple[str, float]], Dict[str, Any]]:
normalized_query = _normalize_text(query)
filters = filters or {}
diagnostics: Dict[str, Any] = {
"cache_hit": False,
"path": "empty",
"candidates": 0,
}
if not normalized_query:
return [], diagnostics
cache_key = self._cache_key(normalized_query, limit, filters)
cached = self._cache.get(cache_key)
if cached is not None:
self._cache.move_to_end(cache_key)
diagnostics.update({"cache_hit": True, "path": "cache", "candidates": len(cached)})
return list(cached), diagnostics
query_tokens = _tokenize(normalized_query)
exact_ids = set(self._exact_index.get(normalized_query, set()))
token_sets: List[set[str]] = []
prefix_sets: List[set[str]] = []
for token in query_tokens:
exact_token_ids = set(self._token_index.get(token, set()))
prefix_ids = set(self._prefix_index.get(token, set())) if len(token) >= self.prefix_min_length else set()
if exact_token_ids:
token_sets.append(exact_token_ids)
if prefix_ids:
prefix_sets.append(prefix_ids)
candidate_ids: set[str] = set(exact_ids)
if token_sets:
intersected = set.intersection(*token_sets)
candidate_ids.update(intersected if intersected else set().union(*token_sets))
if prefix_sets:
candidate_ids.update(set().union(*prefix_sets))
diagnostics["path"] = "index"
if not candidate_ids:
diagnostics["path"] = "secondary_scan"
candidate_ids = self._secondary_scan(normalized_query, limit)
diagnostics["candidates"] = len(candidate_ids)
scored: List[Tuple[float, int, int, str]] = []
for node_id in candidate_ids:
document = self._documents.get(node_id)
if document is None or not self._passes_filters(document, filters):
continue
score = self._score_document(document, normalized_query, query_tokens)
if score <= 0:
continue
token_hits = sum(1 for token in query_tokens if token in document.tokens)
exactness = 1 if normalized_query == document.normalized_id or normalized_query in document.exact_terms else 0
scored.append((score, exactness, token_hits, node_id))
top_matches = heapq.nlargest(limit, scored, key=lambda item: (item[0], item[1], item[2], item[3]))
results = [(node_id, round(score, 4)) for score, _, _, node_id in top_matches]
self._store_cache(cache_key, results)
return results, diagnostics
def _secondary_scan(self, normalized_query: str, limit: int) -> set[str]:
matches: set[str] = set()
max_hits = max(limit * 20, 200)
scanned = 0
for node_id in self._ordered_node_ids:
if scanned >= self.secondary_scan_limit or len(matches) >= max_hits:
break
scanned += 1
document = self._documents.get(node_id)
if document is None:
continue
if normalized_query in document.primary_text or normalized_query in document.secondary_text:
matches.add(node_id)
return matches
def _score_document(
self,
document: IndexedNodeDocument,
normalized_query: str,
query_tokens: Tuple[str, ...],
) -> float:
score = 0.0
if normalized_query == document.normalized_id:
score = max(score, 140.0)
elif normalized_query in document.exact_terms:
score = max(score, 120.0)
if normalized_query and normalized_query in document.primary_text:
score = max(score, 78.0 + min(len(normalized_query), 24) / 10.0)
elif normalized_query and normalized_query in document.secondary_text:
score = max(score, 26.0 + min(len(normalized_query), 24) / 20.0)
token_hits = 0
prefix_hits = 0
for token in query_tokens:
if token in document.tokens:
token_hits += 1
elif len(token) >= self.prefix_min_length and any(candidate.startswith(token) for candidate in document.tokens):
prefix_hits += 1
score += token_hits * 18.0
score += prefix_hits * 10.0
if len(query_tokens) > 1 and token_hits:
score += token_hits * 4.0
return score
def _passes_filters(self, document: IndexedNodeDocument, filters: Dict[str, Any]) -> bool:
filter_type = filters.get("type") or filters.get("node_type")
if filter_type and document.node_type != str(filter_type):
return False
min_confidence = _coerce_float(filters.get("min_confidence"))
if min_confidence is not None:
if document.confidence is None or document.confidence < min_confidence:
return False
tags_filter = filters.get("tags")
if tags_filter:
if isinstance(tags_filter, str):
required_tags = {_normalize_text(tags_filter)}
else:
required_tags = {
normalized
for normalized in (_normalize_text(tag) for tag in tags_filter)
if normalized
}
if required_tags and not required_tags.issubset(set(document.tags)):
return False
return True
def _cache_key(
self,
normalized_query: str,
limit: int,
filters: Dict[str, Any],
) -> Tuple[Any, ...]:
serialized_filters: List[Tuple[str, Any]] = []
for key in sorted(filters.keys()):
value = filters[key]
if isinstance(value, (list, tuple, set)):
serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
else:
serialized_filters.append((key, str(value)))
return normalized_query, limit, tuple(serialized_filters)
def _store_cache(self, cache_key: Tuple[Any, ...], results: List[Tuple[str, float]]) -> None:
self._cache[cache_key] = list(results)
self._cache.move_to_end(cache_key)
while len(self._cache) > self.cache_size:
self._cache.popitem(last=False)
def _build_document(self, node: Dict[str, Any]) -> IndexedNodeDocument:
node_id = str(node.get("id", "")).strip()
node_type = str(node.get("type", "entity"))
properties = dict(node.get("properties", {}) or {})
primary_terms: List[str] = []
for candidate in (node_id, node.get("content", "")):
normalized = _normalize_text(candidate)
if normalized:
primary_terms.append(normalized)
for alias_key in _CURATED_ALIAS_KEYS:
_collect_text_fragments(properties.get(alias_key), primary_terms, limit=32)
deduped_primary_terms = tuple(dict.fromkeys(term for term in primary_terms if term))
primary_text = " ".join(deduped_primary_terms)
tokens = frozenset(_tokenize(primary_text))
secondary_fragments: List[str] = []
for key, value in properties.items():
if key in _CURATED_ALIAS_KEYS or key in {"content", "valid_from", "valid_until"}:
continue
_collect_text_fragments(value, secondary_fragments, limit=48)
if len(secondary_fragments) >= 48:
break
secondary_text = " ".join(dict.fromkeys(fragment for fragment in secondary_fragments if fragment))
confidence = _coerce_float(properties.get("confidence"))
raw_tags = properties.get("tags") or []
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
tags = tuple(
dict.fromkeys(
normalized for normalized in (_normalize_text(tag) for tag in raw_tags) if normalized
)
)
return IndexedNodeDocument(
node_id=node_id,
normalized_id=_normalize_text(node_id),
node_type=node_type,
exact_terms=frozenset(deduped_primary_terms),
tokens=tokens,
primary_text=primary_text,
secondary_text=secondary_text,
confidence=confidence,
tags=tags,
)
+100 -58
View File
@@ -4,12 +4,15 @@ Semantica Explorer session helpers.
import base64 import base64
import json import json
import logging
import threading import threading
import time
import uuid import uuid
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
from ..context.context_graph import ContextGraph, _resolve_edge_identity from ..context.context_graph import ContextGraph, _resolve_edge_identity
from .search_index import GraphSearchIndex
_KG_AVAILABLE = False _KG_AVAILABLE = False
try: try:
@@ -28,6 +31,8 @@ try:
except ImportError: except ImportError:
pass pass
logger = logging.getLogger(__name__)
class GraphSession: class GraphSession:
"""Thread-safe session wrapper around a loaded ``ContextGraph``.""" """Thread-safe session wrapper around a loaded ``ContextGraph``."""
@@ -35,6 +40,7 @@ class GraphSession:
def __init__(self, graph: ContextGraph) -> None: def __init__(self, graph: ContextGraph) -> None:
self.graph = graph self.graph = graph
self._lock = threading.RLock() self._lock = threading.RLock()
self._search_index = GraphSearchIndex()
self.annotations: Dict[str, Dict[str, Any]] = {} self.annotations: Dict[str, Dict[str, Any]] = {}
@@ -46,6 +52,7 @@ class GraphSession:
self._similarity: Any = None self._similarity: Any = None
self._link_predictor: Any = None self._link_predictor: Any = None
self._validator: Any = None self._validator: Any = None
self.rebuild_search_index()
@classmethod @classmethod
def from_file(cls, path: str) -> "GraphSession": def from_file(cls, path: str) -> "GraphSession":
@@ -390,6 +397,28 @@ class GraphSession:
with self._lock: with self._lock:
return self.graph.get_neighbors(node_id, hops=depth) return self.graph.get_neighbors(node_id, hops=depth)
def rebuild_search_index(self) -> None:
with self._lock:
normalized_nodes = [
self.normalize_node(node.to_dict())
for node in self.graph.nodes.values()
if node is not None
]
self._search_index.rebuild(normalized_nodes)
def handle_graph_mutation(self, event_type: str, entity_id: str, payload: Dict[str, Any]) -> None:
normalized_event = str(event_type or "").upper()
if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
normalized_node = self.normalize_node(payload or {})
if normalized_node.get("id"):
with self._lock:
self._search_index.upsert(normalized_node)
elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
with self._lock:
self._search_index.remove(str(entity_id))
elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
self.rebuild_search_index()
def search( def search(
self, self,
query: str, query: str,
@@ -397,64 +426,34 @@ class GraphSession:
filters: Optional[Dict[str, Any]] = None, filters: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
filters = filters or {} filters = filters or {}
try: started_at = time.perf_counter()
with self._lock: matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
raw = self.graph.query(query)[:limit]
except Exception:
raw = []
if not raw: normalized_results: List[Dict[str, Any]] = []
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit)) with self._lock:
scored = [] for node_id, score in matches:
lowered_query = query.lower().strip() raw_node = self.graph.find_node(node_id)
for node in nodes: if raw_node is None:
haystacks = [
str(node.get("id", "")),
str(node.get("content", "")),
json.dumps(node.get("properties", {}), default=str),
]
best_score = 0.0
for haystack in haystacks:
lowered = haystack.lower()
if lowered == lowered_query:
best_score = max(best_score, 1.0)
elif lowered_query in lowered:
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
if best_score > 0:
scored.append({"node": node, "score": round(best_score, 4)})
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
normalized = []
for result in raw:
result_node = result.get("node", {})
node = (
self.normalize_node(result_node)
if "properties" in result_node or "metadata" in result_node or "content" in result_node
else result_node
)
filter_type = filters.get("type") or filters.get("node_type")
if filter_type and node["type"] != filter_type:
continue
min_confidence = self._coerce_float(filters.get("min_confidence"))
node_confidence = self._coerce_float(node["properties"].get("confidence"))
if min_confidence is not None and (
node_confidence is None or node_confidence < min_confidence
):
continue
tags_filter = filters.get("tags")
if tags_filter:
node_tags = node["properties"].get("tags") or []
if isinstance(node_tags, str):
node_tags = [node_tags]
if not set(tags_filter).issubset(set(node_tags)):
continue continue
node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
normalized_results.append(
{
"node": self.normalize_node(node_payload),
"score": score,
}
)
normalized.append({"node": node, "score": result.get("score", 0.0)}) duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
logger.debug(
return normalized[:limit] "Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
query,
limit,
diagnostics.get("cache_hit"),
diagnostics.get("path"),
diagnostics.get("candidates"),
duration_ms,
)
return normalized_results[:limit]
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
with self._lock: with self._lock:
@@ -594,8 +593,51 @@ class GraphSession:
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int: def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
with self._lock: with self._lock:
return self.graph.add_nodes(nodes) added = self.graph.add_nodes(nodes)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
self.rebuild_search_index()
return added
def add_edges(self, edges: List[Dict[str, Any]]) -> int: def add_edges(self, edges: List[Dict[str, Any]]) -> int:
with self._lock: with self._lock:
return self.graph.add_edges(edges) added = self.graph.add_edges(edges)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
self.rebuild_search_index()
return added
def add_node(
self,
node_id: str,
node_type: str,
content: Optional[str] = None,
**properties: Any,
) -> bool:
with self._lock:
added = self.graph.add_node(node_id, node_type, content=content, **properties)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
normalized = self.get_node(node_id)
if normalized is not None:
self._search_index.upsert(normalized)
return added
def add_edge(
self,
source_id: str,
target_id: str,
edge_type: str = "related_to",
weight: float = 1.0,
**properties: Any,
) -> bool:
with self._lock:
added = self.graph.add_edge(
source_id,
target_id,
edge_type=edge_type,
weight=weight,
**properties,
)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
return added
+2
View File
@@ -162,6 +162,7 @@ License: MIT
""" """
from .arango_aql_exporter import ArangoAQLExporter from .arango_aql_exporter import ArangoAQLExporter
from .distance_exporter import DistanceExporter
from .config import ExportConfig, export_config from .config import ExportConfig, export_config
try: try:
@@ -220,6 +221,7 @@ __all__ = [
# Core Exporters # Core Exporters
"ArrowExporter", "ArrowExporter",
"ArangoAQLExporter", "ArangoAQLExporter",
"DistanceExporter",
"RDFExporter", "RDFExporter",
"RDFSerializer", "RDFSerializer",
"RDFValidator", "RDFValidator",
+226
View File
@@ -0,0 +1,226 @@
"""
Distance-Enriched Export (FR-10)
Exports pairwise node distance metrics hop count, weighted distance,
semantic similarity, distance band, betweenness centrality in CSV or
JSONL format for downstream ML pipelines (GNN training, clustering,
link prediction).
Python API:
exporter = DistanceExporter(graph)
df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"])
exporter.to_csv("distances.csv")
exporter.to_jsonl("distances.jsonl")
"""
import csv
import io
import json
from typing import Any, Dict, List, Optional
from ..utils.helpers import classify_path_distance
from ..utils.logging import get_logger
logger = get_logger(__name__)
_KG_AVAILABLE = False
try:
from ..kg import PathFinder, SimilarityCalculator, CentralityCalculator
_KG_AVAILABLE = True
except ImportError as exc:
logger.debug("KG components not available; distance exporter will run in reduced mode: %s", exc)
_ALL_COLUMNS = [
"source_id", "source_type", "target_id", "target_type",
"hop_count", "weighted_distance", "semantic_similarity",
"distance_band", "source_betweenness", "target_betweenness",
]
class DistanceExporter:
"""Compute and export pairwise distance metrics for a ContextGraph."""
def __init__(self, graph: Any) -> None:
self.graph = graph
self._path_finder = PathFinder() if _KG_AVAILABLE else None
self._similarity = SimilarityCalculator() if _KG_AVAILABLE else None
self._centrality = CentralityCalculator() if _KG_AVAILABLE else None
def _build_graph_dict(self) -> Dict[str, Any]:
nodes = [
{"id": n.node_id, "type": n.node_type, "content": n.content, "properties": n.properties}
for n in self.graph.nodes.values()
]
edges_raw = getattr(self.graph, "edges", [])
edges = [
{
"id": e.edge_id, "source": e.source_id, "target": e.target_id,
"type": e.edge_type, "weight": e.weight,
}
for e in edges_raw
]
return {"nodes": nodes, "edges": edges}
def _node_type(self, node_id: str) -> str:
node = getattr(self.graph, "nodes", {}).get(node_id)
return getattr(node, "node_type", "") if node else ""
def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]:
if self._centrality is None:
return {}
try:
result = self._centrality.calculate_betweenness_centrality(graph_dict)
return result.get("betweenness", {}) if isinstance(result, dict) else {}
except Exception:
return {}
def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]:
if self._path_finder is None:
return None
try:
result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt)
path = result.get("path", []) if isinstance(result, dict) else (result or [])
return len(path) - 1 if path else None
except Exception:
return None
def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
if self._path_finder is None:
return None
try:
result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt)
if isinstance(result, dict):
return float(result.get("total_weight", len(result.get("path", [])) - 1))
return None
except Exception:
return None
def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
if self._similarity is None:
return None
try:
sim = self._similarity.cosine_similarity(graph_dict, src, tgt)
return float(sim) if isinstance(sim, (int, float)) else None
except Exception:
return None
def compute_pairs(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Compute all pairwise distance metrics and return as a list of dicts."""
include_set = set(include or _ALL_COLUMNS)
graph_dict = self._build_graph_dict()
node_ids = node_subset or list(self.graph.nodes.keys())
betweenness: Dict[str, float] = {}
if "source_betweenness" in include_set or "target_betweenness" in include_set:
betweenness = self._betweenness(graph_dict)
rows = []
for i, src in enumerate(node_ids):
for tgt in node_ids:
if src == tgt:
continue
row: Dict[str, Any] = {}
if "source_id" in include_set:
row["source_id"] = src
if "source_type" in include_set:
row["source_type"] = self._node_type(src)
if "target_id" in include_set:
row["target_id"] = tgt
if "target_type" in include_set:
row["target_type"] = self._node_type(tgt)
hop_count: Optional[int] = None
if "hop_count" in include_set or "distance_band" in include_set:
hop_count = self._hop_distance(graph_dict, src, tgt)
if "hop_count" in include_set:
row["hop_count"] = hop_count
if "weighted_distance" in include_set:
row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt)
if "semantic_similarity" in include_set:
row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt)
if "distance_band" in include_set:
row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant"
if "source_betweenness" in include_set:
row["source_betweenness"] = betweenness.get(src)
if "target_betweenness" in include_set:
row["target_betweenness"] = betweenness.get(tgt)
rows.append(row)
return rows
def to_dataframe(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> Any:
"""Return a pandas DataFrame of pairwise distances."""
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required for to_dataframe()") from exc
rows = self.compute_pairs(include=include, node_subset=node_subset)
return pd.DataFrame(rows)
def to_csv(
self,
path: str,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> None:
"""Write pairwise distances to a CSV file."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
if not rows:
with open(path, "w", newline="", encoding="utf-8") as fh:
fh.write("")
return
fieldnames = list(rows[0].keys())
with open(path, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def to_jsonl(
self,
path: str,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> None:
"""Write pairwise distances to a JSONL file (one JSON object per line)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
with open(path, "w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, default=str) + "\n")
def to_csv_string(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> str:
"""Return CSV as a string (for API responses)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
if not rows:
return ""
buf = io.StringIO()
fieldnames = list(rows[0].keys())
writer = csv.DictWriter(buf, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return buf.getvalue()
def to_jsonl_string(
self,
include: Optional[List[str]] = None,
node_subset: Optional[List[str]] = None,
) -> str:
"""Return JSONL as a string (for API responses)."""
rows = self.compute_pairs(include=include, node_subset=node_subset)
return "\n".join(json.dumps(row, default=str) for row in rows)
+63 -40
View File
@@ -328,6 +328,18 @@ class OWLExporter:
lines.append("</rdf:RDF>") lines.append("</rdf:RDF>")
return "\n".join(lines) return "\n".join(lines)
@staticmethod
def _escape_ttl_str(value: str) -> str:
"""Escape a string value for safe embedding in a Turtle string literal."""
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
def _ttl_block(self, subject_uri: str, rdf_type: str, predicates: List[str]) -> str:
"""Build a valid Turtle subject block from accumulated predicate strings."""
stmt = f"<{subject_uri}> a {rdf_type}"
for pred in predicates:
stmt += f" ;\n {pred}"
return stmt + " ."
def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str: def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str:
""" """
Export ontology to OWL Turtle format. Export ontology to OWL Turtle format.
@@ -342,6 +354,7 @@ class OWLExporter:
Returns: Returns:
String containing OWL Turtle serialization String containing OWL Turtle serialization
""" """
esc = self._escape_ttl_str
ontology_uri = ontology.get("uri") or self.ontology_uri ontology_uri = ontology.get("uri") or self.ontology_uri
ontology_name = ontology.get("name", "SemanticaOntology") ontology_name = ontology.get("name", "SemanticaOntology")
version = ontology.get("version") or self.version version = ontology.get("version") or self.version
@@ -357,63 +370,73 @@ class OWLExporter:
lines.append("") lines.append("")
# Ontology declaration # Ontology declaration
lines.append(f"<{ontology_uri}> a owl:Ontology ;") onto_predicates = [
lines.append(f' rdfs:label "{ontology_name}" ;') f'rdfs:label "{esc(ontology_name)}"',
lines.append(f' owl:versionInfo "{version}" .') f'owl:versionInfo "{esc(version)}"',
if ontology.get("description"): ]
lines.append(f' rdfs:comment "{ontology.get("description")}" ;') description = ontology.get("description")
if description:
onto_predicates.append(f'rdfs:comment "{esc(description)}"')
lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
lines.append("") lines.append("")
# Classes # Classes
classes = ontology.get("classes", []) for cls in ontology.get("classes", []):
for cls in classes:
class_uri = cls.get("uri") or cls.get("id", "") class_uri = cls.get("uri") or cls.get("id", "")
class_name = cls.get("name") or cls.get("label", "") class_name = cls.get("name") or cls.get("label", "")
predicates = [f'rdfs:label "{esc(class_name)}"']
lines.append(f"<{class_uri}> a owl:Class ;") comment = cls.get("comment")
lines.append(f' rdfs:label "{class_name}" .') if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
if cls.get("comment"): sub_class = cls.get("subClassOf")
lines.append(f' rdfs:comment "{cls.get("comment")}" ;') if sub_class:
predicates.append(f"rdfs:subClassOf <{sub_class}>")
if cls.get("subClassOf"): equiv = cls.get("equivalentClass")
parent = cls.get("subClassOf") if equiv:
lines.append(f" rdfs:subClassOf <{parent}> ;") predicates.append(f"owl:equivalentClass <{equiv}>")
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
# Remove trailing semicolon and add period
if lines[-1].endswith(" ;"):
lines[-1] = lines[-1].rstrip(" ;") + " ."
else:
lines.append(" .")
lines.append("") lines.append("")
# Object properties # Object properties
object_properties = ontology.get("object_properties", []) for prop in ontology.get("object_properties", []):
for prop in object_properties:
prop_uri = prop.get("uri") or prop.get("id", "") prop_uri = prop.get("uri") or prop.get("id", "")
prop_name = prop.get("name") or prop.get("label", "") prop_name = prop.get("name") or prop.get("label", "")
predicates = [f'rdfs:label "{esc(prop_name)}"']
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;") comment = prop.get("comment")
lines.append(f' rdfs:label "{prop_name}" .') if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
if prop.get("domain"): domain = prop.get("domain")
domain = prop.get("domain") if domain:
if isinstance(domain, list): if isinstance(domain, list):
for d in domain: for d in domain:
lines.append(f" rdfs:domain <{d}> ;") predicates.append(f"rdfs:domain <{d}>")
else: else:
lines.append(f" rdfs:domain <{domain}> ;") predicates.append(f"rdfs:domain <{domain}>")
range_val = prop.get("range")
if prop.get("range"): if range_val:
range_val = prop.get("range")
if isinstance(range_val, list): if isinstance(range_val, list):
for r in range_val: for r in range_val:
lines.append(f" rdfs:range <{r}> ;") predicates.append(f"rdfs:range <{r}>")
else: else:
lines.append(f" rdfs:range <{range_val}> ;") predicates.append(f"rdfs:range <{range_val}>")
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
lines.append("")
if lines[-1].endswith(" ;"): # Data properties
lines[-1] = lines[-1].rstrip(" ;") + " ." for prop in ontology.get("data_properties", []):
prop_uri = prop.get("uri") or prop.get("id", "")
prop_name = prop.get("name") or prop.get("label", "")
predicates = [f'rdfs:label "{esc(prop_name)}"']
comment = prop.get("comment")
if comment:
predicates.append(f'rdfs:comment "{esc(comment)}"')
domain = prop.get("domain")
if domain:
predicates.append(f"rdfs:domain <{domain}>")
range_type = prop.get("range")
if range_type:
predicates.append(f"rdfs:range xsd:{range_type}")
lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
lines.append("") lines.append("")
return "\n".join(lines) return "\n".join(lines)
+6 -3
View File
@@ -397,6 +397,7 @@ class BaseProvider:
create_kwargs["response_format"] = {"type": "json_object"} create_kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**create_kwargs) response = client.chat.completions.create(**create_kwargs)
verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
if verbose_mode: if verbose_mode:
import sys import sys
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout) print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
@@ -939,20 +940,22 @@ class DeepSeekProvider(BaseProvider):
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs): def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.api_key = api_key or config.get_api_key("deepseek") self.api_key = api_key or config.get_api_key("deepseek")
self.base_url = "https://api.deepseek.com/v1"
self.model = model self.model = model
self.base_url = "https://api.deepseek.com/v1"
self.client = None self.client = None
self._init_client() self._init_client()
def _init_client(self): def _init_client(self):
try: try:
import deepseek # type: ignore[import-untyped] from openai import OpenAI
if self.api_key: if self.api_key:
self.client = deepseek.Client(api_key=self.api_key) self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
except (ImportError, OSError): except (ImportError, OSError):
self.client = None self.client = None
self.logger.warning( self.logger.warning(
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]" "openai library not installed. Install with: pip install semantica[llm-openai]"
) )
def is_available(self) -> bool: def is_available(self) -> bool:
+15 -3
View File
@@ -188,10 +188,22 @@ async def serve_spa(full_path: str):
if full_path.startswith("api/"): if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API route not found") raise HTTPException(status_code=404, detail="API route not found")
# Root path — serve index.html if built, otherwise a welcome JSON response
if full_path in ("", "/"):
index_file = STATIC_DIR / "index.html"
if index_file.is_file():
return FileResponse(index_file)
return JSONResponse({
"name": "Semantica Knowledge Explorer",
"version": __version__,
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
"docs": "/docs",
"health": "/health",
})
normalized_path = os.path.normpath(full_path) normalized_path = os.path.normpath(full_path)
if ( if (
normalized_path in ("", ".") os.path.isabs(normalized_path)
or os.path.isabs(normalized_path)
or normalized_path == ".." or normalized_path == ".."
or normalized_path.startswith(".." + os.sep) or normalized_path.startswith(".." + os.sep)
): ):
@@ -200,7 +212,7 @@ async def serve_spa(full_path: str):
# Ensure join remains relative to STATIC_DIR even if input includes leading separators # Ensure join remains relative to STATIC_DIR even if input includes leading separators
safe_rel_path = normalized_path.lstrip("/\\") safe_rel_path = normalized_path.lstrip("/\\")
rel_parts = Path(safe_rel_path).parts rel_parts = Path(safe_rel_path).parts
if any(part in ("", ".", "..") for part in rel_parts): if any(part in (".", "..") for part in rel_parts):
raise HTTPException(status_code=400, detail="Invalid path") raise HTTPException(status_code=400, detail="Invalid path")
static_dir_resolved = STATIC_DIR.resolve() static_dir_resolved = STATIC_DIR.resolve()
+210
View File
@@ -0,0 +1,210 @@
"""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(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(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, "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")]
+78
View File
@@ -36,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120) graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30) graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30)
graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60) graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60)
graph.add_node(
"metformin",
node_type="drug",
content="Metformin",
aliases=["Glucophage"],
confidence="0.97",
tags=["drug", "featured"],
x=22,
y=33,
)
graph.add_node( graph.add_node(
"decision_1", "decision_1",
node_type="decision", node_type="decision",
@@ -244,6 +254,74 @@ class TestSearchAndStats:
assert payload["total"] >= 1 assert payload["total"] >= 1
assert all(item["node"]["type"] == "language" for item in payload["results"]) assert all(item["node"]["type"] == "language" for item in payload["results"])
def test_search_exact_and_prefix(self, client):
exact_response = client.post(
"/api/graph/search",
json={"query": "Metformin", "limit": 5},
)
assert exact_response.status_code == 200
exact_payload = exact_response.json()
assert exact_payload["results"][0]["node"]["id"] == "metformin"
prefix_response = client.post(
"/api/graph/search",
json={"query": "metf", "limit": 5},
)
assert prefix_response.status_code == 200
prefix_payload = prefix_response.json()
assert any(item["node"]["id"] == "metformin" for item in prefix_payload["results"])
def test_search_filters_and_cache_stability(self, client):
body = {
"query": "framework",
"filters": {"type": "decision", "min_confidence": 0.8},
"limit": 5,
}
first_response = client.post("/api/graph/search", json=body)
second_response = client.post("/api/graph/search", json=body)
assert first_response.status_code == 200
assert second_response.status_code == 200
assert first_response.json() == second_response.json()
results = first_response.json()["results"]
assert [item["node"]["id"] for item in results] == ["decision_1"]
def test_search_sees_new_nodes_after_mutation(self, client):
session = client.app.state.session
assert session.add_node(
"metformin_hcl",
"drug",
content="Metformin Hydrochloride",
aliases=["Glucophage XR"],
confidence="0.93",
)
response = client.post(
"/api/graph/search",
json={"query": "glucophage", "limit": 10},
)
assert response.status_code == 200
result_ids = [item["node"]["id"] for item in response.json()["results"]]
assert "metformin" in result_ids
assert "metformin_hcl" in result_ids
def test_search_secondary_scan_fallback_matches_non_curated_properties(self, client):
session = client.app.state.session
assert session.add_node(
"fallback_node",
"entity",
content="Alpha",
description="rareterm",
)
response = client.post(
"/api/graph/search",
json={"query": "rareterm", "limit": 10},
)
assert response.status_code == 200
result_ids = [item["node"]["id"] for item in response.json()["results"]]
assert "fallback_node" in result_ids
def test_stats(self, client): def test_stats(self, client):
response = client.get("/api/graph/stats") response = client.get("/api/graph/stats")
assert response.status_code == 200 assert response.status_code == 200
+74
View File
@@ -0,0 +1,74 @@
"""Unit tests for explorer provenance route helpers."""
from types import SimpleNamespace
from semantica.explorer.routes.provenance import _build_provenance, _render_markdown
def _make_session_with_chain() -> SimpleNamespace:
"""Build a minimal session-like object for Source -> Intermediate -> node_id."""
nodes = {
"Source": SimpleNamespace(node_type="entity", content="Source"),
"Intermediate": SimpleNamespace(node_type="entity", content="Intermediate"),
"node_id": SimpleNamespace(node_type="entity", content="Target"),
}
edges = [
SimpleNamespace(source_id="Source", target_id="Intermediate", edge_type="related_to"),
SimpleNamespace(source_id="Intermediate", target_id="node_id", edge_type="related_to"),
]
graph = SimpleNamespace(nodes=nodes, edges=edges)
return SimpleNamespace(graph=graph)
def test_build_provenance_direction_classification_chain():
session = _make_session_with_chain()
data = _build_provenance(session, "node_id")
node_ids = {node["id"] for node in data["nodes"]}
assert "Source" in node_ids
assert "Intermediate" in node_ids
edge_by_pair = {(edge["source"], edge["target"]): edge for edge in data["edges"]}
assert edge_by_pair[("Intermediate", "node_id")]["direction"] == "upstream"
assert edge_by_pair[("Source", "Intermediate")]["direction"] != "downstream"
def test_render_markdown_groups_edges_by_direction():
report = {
"node_id": "node_id",
"label": "Target",
"type": "entity",
"properties": {},
"lineage": {
"nodes": [
{"id": "Source", "prov_type": "Entity", "label": "Source"},
{"id": "Intermediate", "prov_type": "Entity", "label": "Intermediate"},
{"id": "node_id", "prov_type": "Entity", "label": "Target"},
],
"edges": [
{
"id": "Intermediate-node_id",
"source": "Intermediate",
"target": "node_id",
"label": "related_to",
"direction": "upstream",
},
{
"id": "Source-Intermediate",
"source": "Source",
"target": "Intermediate",
"label": "related_to",
"direction": "lateral",
},
],
},
}
markdown = _render_markdown(report)
assert "## Upstream" in markdown
assert "## Lateral" in markdown
assert "`Intermediate` -[related_to]-> `node_id`" in markdown
assert "`Source` -[related_to]-> `Intermediate`" in markdown
+549
View File
@@ -0,0 +1,549 @@
"""Tests for OWLExporter._export_owl_turtle fixes (issue #478).
Bug 1: invalid Turtle when subClassOf/domain/range present (predicates appended
after a closing period).
Bug 2: data_properties silently dropped from Turtle output.
"""
import pytest
from semantica.export import OWLExporter
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def exporter():
return OWLExporter()
@pytest.fixture
def full_ontology():
return {
"uri": "http://example.org/onto",
"name": "TestOntology",
"description": "A test ontology",
"classes": [
{
"uri": "http://example.org/Person",
"name": "Person",
},
{
"uri": "http://example.org/Employee",
"name": "Employee",
"comment": "A person who is employed",
"subClassOf": "http://example.org/Person",
},
{
"uri": "http://example.org/Manager",
"name": "Manager",
"subClassOf": "http://example.org/Employee",
"equivalentClass": "http://example.org/Supervisor",
},
],
"object_properties": [
{
"uri": "http://example.org/worksFor",
"name": "worksFor",
"domain": "http://example.org/Employee",
"range": "http://example.org/Company",
},
{
"uri": "http://example.org/manages",
"name": "manages",
"comment": "manages a team",
"domain": ["http://example.org/Manager"],
"range": ["http://example.org/Employee"],
},
],
"data_properties": [
{
"uri": "http://example.org/hasAge",
"name": "hasAge",
"domain": "http://example.org/Person",
"range": "integer",
},
{
"uri": "http://example.org/hasName",
"name": "hasName",
"comment": "full name",
"domain": "http://example.org/Person",
"range": "string",
},
],
}
# ---------------------------------------------------------------------------
# Bug 1 — valid Turtle syntax
# ---------------------------------------------------------------------------
class TestTurtleSyntaxValidity:
"""Every subject block must have exactly one closing period at the end."""
def _blocks(self, turtle: str) -> list[str]:
"""Split output into non-empty logical blocks (separated by blank lines)."""
return [b.strip() for b in turtle.split("\n\n") if b.strip()]
def test_no_triple_after_period(self, exporter, full_ontology):
"""No predicate line may appear after a line that ends with ' .'."""
turtle = exporter._export_owl_turtle(full_ontology)
lines = turtle.splitlines()
for i, line in enumerate(lines):
stripped = line.rstrip()
if stripped.endswith(" .") and i + 1 < len(lines):
next_line = lines[i + 1].strip()
# next non-blank line must not be a predicate continuation
if next_line:
assert not next_line.startswith("rdfs:"), (
f"Predicate continuation after closing '.' at line {i + 1}: "
f"{lines[i]!r}{lines[i + 1]!r}"
)
def test_each_subject_block_ends_with_period(self, exporter, full_ontology):
"""Every subject block (class / property declaration) ends with exactly one '.'."""
turtle = exporter._export_owl_turtle(full_ontology)
blocks = self._blocks(turtle)
# skip the @prefix lines block and ontology declaration
subject_blocks = [b for b in blocks if b.startswith("<http://")]
for block in subject_blocks:
assert block.endswith("."), f"Block does not end with '.': {block!r}"
# Must not have a bare '.' on an interior line
interior_lines = block.splitlines()[:-1]
for ln in interior_lines:
assert not ln.rstrip().endswith(" ."), (
f"Premature closing period inside block: {ln!r}"
)
def test_class_with_subclassof_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/Employee",
"name": "Employee",
"subClassOf": "http://example.org/Person",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
# Must contain both predicates in the same block
assert 'rdfs:label "Employee"' in turtle
assert "rdfs:subClassOf <http://example.org/Person>" in turtle
# The subClassOf line must NOT come after a closing period
lines = turtle.splitlines()
for i, ln in enumerate(lines):
if "rdfs:subClassOf" in ln:
# Search backwards for the closest period-terminated line
for prev in reversed(lines[:i]):
prev_s = prev.rstrip()
if prev_s:
assert not prev_s.endswith(" ."), (
"rdfs:subClassOf appeared after a closed block"
)
break
def test_object_property_with_domain_range_is_valid(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/worksFor",
"name": "worksFor",
"domain": "http://example.org/Employee",
"range": "http://example.org/Company",
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain <http://example.org/Employee>" in turtle
assert "rdfs:range <http://example.org/Company>" in turtle
lines = turtle.splitlines()
for i, ln in enumerate(lines):
if "rdfs:domain" in ln or "rdfs:range" in ln:
for prev in reversed(lines[:i]):
prev_s = prev.rstrip()
if prev_s:
assert not prev_s.endswith(" ."), (
"domain/range appeared after a closed block"
)
break
def test_class_with_comment_subclassof_both_present(self, exporter):
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [
{
"uri": "http://example.org/X",
"name": "X",
"comment": "some comment",
"subClassOf": "http://example.org/Y",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:comment "some comment"' in turtle
assert "rdfs:subClassOf <http://example.org/Y>" in turtle
# block must end with single period
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
assert block.endswith(".")
assert block.count("\n.") == 0 # no bare period-only lines
# ---------------------------------------------------------------------------
# Bug 2 — data properties present in Turtle output
# ---------------------------------------------------------------------------
class TestDataPropertiesInTurtle:
def test_data_property_declared_as_datatypeproperty(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "owl:DatatypeProperty" in turtle
def test_data_property_uri_present(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "<http://example.org/hasAge>" in turtle
assert "<http://example.org/hasName>" in turtle
def test_data_property_label(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:label "hasAge"' in turtle
assert 'rdfs:label "hasName"' in turtle
def test_data_property_domain(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "rdfs:domain <http://example.org/Person>" in turtle
def test_data_property_range_uses_xsd_prefix(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "rdfs:range xsd:integer" in turtle
assert "rdfs:range xsd:string" in turtle
def test_data_property_comment(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:comment "full name"' in turtle
def test_data_properties_not_in_turtle_was_bug(self, exporter):
"""Regression: data_properties were silently dropped before the fix."""
ontology = {
"uri": "http://example.org/onto",
"name": "T",
"classes": [],
"object_properties": [],
"data_properties": [
{
"uri": "http://example.org/birthDate",
"name": "birthDate",
"range": "date",
}
],
}
turtle = exporter._export_owl_turtle(ontology)
assert "owl:DatatypeProperty" in turtle, (
"Data properties must appear in Turtle output (was silently dropped)"
)
assert "<http://example.org/birthDate>" in turtle
assert "rdfs:range xsd:date" in turtle
def test_data_property_block_ends_with_period(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
blocks = [b.strip() for b in turtle.split("\n\n") if "owl:DatatypeProperty" in b]
assert blocks, "Expected at least one DatatypeProperty block"
for block in blocks:
assert block.endswith("."), f"DatatypeProperty block missing closing '.': {block!r}"
# ---------------------------------------------------------------------------
# Namespace and ontology header
# ---------------------------------------------------------------------------
class TestTurtleHeader:
def test_prefix_declarations(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "@prefix rdf:" in turtle
assert "@prefix rdfs:" in turtle
assert "@prefix owl:" in turtle
assert "@prefix xsd:" in turtle
def test_ontology_declaration(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert "a owl:Ontology" in turtle
assert 'rdfs:label "TestOntology"' in turtle
assert 'owl:versionInfo "1.0"' in turtle
def test_ontology_description_included(self, exporter, full_ontology):
turtle = exporter._export_owl_turtle(full_ontology)
assert 'rdfs:comment "A test ontology"' in turtle
def test_ontology_without_description(self, exporter):
ontology = {"uri": "http://example.org/onto", "name": "NoDesc",
"classes": [], "object_properties": [], "data_properties": []}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:comment" not in turtle
# ---------------------------------------------------------------------------
# Object properties — list domain/range
# ---------------------------------------------------------------------------
class TestObjectPropertyListDomainRange:
def test_list_domain(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/p",
"name": "p",
"domain": ["http://example.org/A", "http://example.org/B"],
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain <http://example.org/A>" in turtle
assert "rdfs:domain <http://example.org/B>" in turtle
def test_list_range(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [],
"object_properties": [
{
"uri": "http://example.org/p",
"name": "p",
"range": ["http://example.org/X", "http://example.org/Y"],
}
],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range <http://example.org/X>" in turtle
assert "rdfs:range <http://example.org/Y>" in turtle
# ---------------------------------------------------------------------------
# equivalentClass support (also tested under Bug 1 guard)
# ---------------------------------------------------------------------------
class TestEquivalentClass:
def test_equivalent_class_in_turtle(self, exporter):
ontology = {
"uri": "http://example.org/onto", "name": "T",
"classes": [
{
"uri": "http://example.org/Manager",
"name": "Manager",
"equivalentClass": "http://example.org/Supervisor",
}
],
"object_properties": [],
"data_properties": [],
}
turtle = exporter._export_owl_turtle(ontology)
assert "owl:equivalentClass <http://example.org/Supervisor>" in turtle
block = [b for b in turtle.split("\n\n") if "owl:Class" in b][0].strip()
assert block.endswith(".")
# ---------------------------------------------------------------------------
# String escaping in Turtle literals (issue #478 review — escape_001)
# ---------------------------------------------------------------------------
class TestTurtleStringEscaping:
"""User-provided strings must be escaped before embedding in Turtle literals."""
def _onto(self, **kwargs):
base = {"uri": "http://example.org/onto", "name": "T",
"classes": [], "object_properties": [], "data_properties": []}
base.update(kwargs)
return base
def test_escape_ttl_str_double_quote(self, exporter):
assert exporter._escape_ttl_str('say "hello"') == r'say \"hello\"'
def test_escape_ttl_str_backslash(self, exporter):
assert exporter._escape_ttl_str("C:\\path") == "C:\\\\path"
def test_escape_ttl_str_newline(self, exporter):
assert exporter._escape_ttl_str("line1\nline2") == "line1\\nline2"
def test_escape_ttl_str_carriage_return(self, exporter):
assert exporter._escape_ttl_str("a\rb") == "a\\rb"
def test_escape_ttl_str_tab(self, exporter):
assert exporter._escape_ttl_str("col1\tcol2") == "col1\\tcol2"
def test_escape_ttl_str_combined(self, exporter):
raw = 'back\\slash and "quote"\nnewline'
escaped = exporter._escape_ttl_str(raw)
assert '\\"' in escaped
assert "\\\\" in escaped
assert "\\n" in escaped
def test_ontology_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(name='John"s Ontology')
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:label "John\\"s Ontology"' in turtle
assert 'rdfs:label "John"s Ontology"' not in turtle
def test_ontology_description_with_quote_is_escaped(self, exporter):
ontology = self._onto(description='Describes "things"')
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:comment "Describes \\"things\\""' in turtle
def test_class_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": 'My "Special" Class',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "My \"Special\" Class"' in turtle
def test_class_comment_with_backslash_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": "C",
"comment": "path is C:\\Users",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "path is C:\\Users"' in turtle
def test_class_comment_with_newline_is_escaped(self, exporter):
ontology = self._onto(classes=[{
"uri": "http://example.org/C",
"name": "C",
"comment": "line1\nline2",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "line1\nline2"' in turtle
def test_object_property_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p",
"name": 'has"Value',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "has\"Value"' in turtle
def test_object_property_comment_with_quote_is_escaped(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p",
"name": "p",
"comment": 'links "A" to "B"',
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "links \"A\" to \"B\""' in turtle
def test_data_property_name_with_quote_is_escaped(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp",
"name": 'the "name" prop',
"range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:label "the \"name\" prop"' in turtle
def test_data_property_comment_with_quote_is_escaped(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp",
"name": "dp",
"comment": 'see "spec" §3',
"range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert r'rdfs:comment "see \"spec\" §3"' in turtle
def test_plain_strings_unchanged(self, exporter):
"""Strings without special chars must pass through unchanged."""
ontology = self._onto(
name="MyOntology",
classes=[{"uri": "http://example.org/C", "name": "SafeName"}],
)
turtle = exporter._export_owl_turtle(ontology)
assert 'rdfs:label "MyOntology"' in turtle
assert 'rdfs:label "SafeName"' in turtle
# ---------------------------------------------------------------------------
# Null / missing optional fields — no KeyError raised (review null_check_001-3)
# ---------------------------------------------------------------------------
class TestNullFieldHandling:
"""Optional fields absent from dicts must not raise KeyError."""
def _onto(self, **kwargs):
base = {"uri": "http://example.org/onto", "name": "T",
"classes": [], "object_properties": [], "data_properties": []}
base.update(kwargs)
return base
def test_class_no_optional_fields(self, exporter):
ontology = self._onto(classes=[{"uri": "http://example.org/C", "name": "C"}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:Class" in turtle
def test_object_property_no_domain_no_range(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p"
}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:ObjectProperty" in turtle
assert "rdfs:domain" not in turtle
assert "rdfs:range" not in turtle
def test_data_property_no_domain_no_range(self, exporter):
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp"
}])
turtle = exporter._export_owl_turtle(ontology)
assert "owl:DatatypeProperty" in turtle
assert "rdfs:domain" not in turtle
assert "rdfs:range" not in turtle
def test_data_property_none_domain(self, exporter):
"""Explicit None value for domain must not raise KeyError."""
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp",
"domain": None, "range": "string",
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain" not in turtle
def test_data_property_none_range(self, exporter):
"""Explicit None value for range must not raise KeyError."""
ontology = self._onto(data_properties=[{
"uri": "http://example.org/dp", "name": "dp",
"domain": "http://example.org/C", "range": None,
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range" not in turtle
def test_object_property_none_domain(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p",
"domain": None, "range": "http://example.org/X",
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:domain" not in turtle
def test_object_property_none_range(self, exporter):
ontology = self._onto(object_properties=[{
"uri": "http://example.org/p", "name": "p",
"domain": "http://example.org/A", "range": None,
}])
turtle = exporter._export_owl_turtle(ontology)
assert "rdfs:range" not in turtle
@@ -0,0 +1,348 @@
"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
import sys
import os
import unittest
from unittest.mock import patch, MagicMock, call
from pydantic import BaseModel
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
class TestDeepSeekProviderInit(unittest.TestCase):
"""Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
def setUp(self):
from semantica.semantic_extract.providers import DeepSeekProvider
self.DeepSeekProvider = DeepSeekProvider
def test_base_url_set_on_init(self):
"""self.base_url must be set before _init_client is called (PR #482 regression)."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="fake-key")
self.assertTrue(
hasattr(provider, "base_url"),
"DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
)
self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
def test_base_url_points_to_v1_endpoint(self):
"""base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="fake-key")
self.assertIn("/v1", provider.base_url, "base_url must include /v1")
def test_init_client_uses_openai_not_deepseek(self):
"""_init_client must import openai.OpenAI, not deepseek.Client."""
mock_openai_cls = MagicMock()
mock_openai_instance = MagicMock()
mock_openai_cls.return_value = mock_openai_instance
with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
# Re-import to pick up patched sys.modules
import importlib
import semantica.semantic_extract.providers as providers_mod
importlib.reload(providers_mod)
DeepSeekProvider = providers_mod.DeepSeekProvider
provider = DeepSeekProvider(api_key="sk-test")
mock_openai_cls.assert_called_once_with(
api_key="sk-test",
base_url="https://api.deepseek.com/v1",
)
self.assertIs(provider.client, mock_openai_instance)
def test_init_client_no_api_key_leaves_client_none(self):
"""Without an API key, client must remain None."""
with patch("semantica.semantic_extract.providers.config") as mock_cfg:
mock_cfg.get_api_key.return_value = None
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key=None)
provider.client = None # simulate _init_client no-op
self.assertFalse(provider.is_available())
def test_init_client_handles_openai_import_error(self):
"""If openai is not installed, _init_client must set client=None, not raise."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None # manually simulate ImportError path
# Directly call _init_client with openai blocked
with patch.dict("sys.modules", {"openai": None}):
try:
provider._init_client()
except Exception as e:
self.fail(f"_init_client raised unexpectedly: {e}")
self.assertIsNone(provider.client)
def test_is_available_true_when_client_set(self):
"""is_available() returns True when self.client is an OpenAI instance."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = MagicMock()
self.assertTrue(provider.is_available())
def test_is_available_false_when_client_none(self):
"""is_available() returns False when self.client is None."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None
self.assertFalse(provider.is_available())
def test_no_deepseek_module_imported(self):
"""deepseek module must NOT be imported by _init_client after PR #482."""
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
provider = self.DeepSeekProvider(api_key="sk-test")
provider.client = None
blocked = MagicMock()
blocked.__spec__ = None
with patch.dict("sys.modules", {"deepseek": None}):
# _init_client should succeed even if deepseek is completely absent
mock_openai = MagicMock()
mock_openai.OpenAI.return_value = MagicMock()
with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
try:
provider._init_client()
except Exception as e:
self.fail(f"_init_client raised when deepseek absent: {e}")
class TestDeepSeekProviderGenerate(unittest.TestCase):
"""Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
def _make_provider(self, api_key="sk-test"):
from semantica.semantic_extract.providers import DeepSeekProvider
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key=api_key)
provider.client = MagicMock()
return provider
def test_generate_uses_chat_completions(self):
"""generate() must call client.chat.completions.create."""
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = "hello"
provider.client.chat.completions.create.return_value = mock_resp
result = provider.generate("test prompt")
provider.client.chat.completions.create.assert_called_once()
self.assertEqual(result, "hello")
def test_generate_passes_model(self):
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = "x"
provider.client.chat.completions.create.return_value = mock_resp
provider.generate("p", model="deepseek-reasoner")
kwargs = provider.client.chat.completions.create.call_args[1]
self.assertEqual(kwargs["model"], "deepseek-reasoner")
def test_generate_structured_returns_parsed_json(self):
provider = self._make_provider()
mock_resp = MagicMock()
mock_resp.choices[0].message.content = '{"key": "value"}'
provider.client.chat.completions.create.return_value = mock_resp
result = provider.generate_structured("test prompt")
self.assertEqual(result, {"key": "value"})
def test_generate_raises_without_client(self):
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = None
with self.assertRaises(ProcessingError):
provider.generate("prompt")
def test_generate_structured_raises_without_client(self):
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = None
with self.assertRaises(ProcessingError):
provider.generate_structured("prompt")
class TestDeepSeekInstructorPath(unittest.TestCase):
"""Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
def _make_provider(self, api_key="sk-test"):
from semantica.semantic_extract.providers import DeepSeekProvider
from unittest.mock import MagicMock
from openai import OpenAI
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key=api_key)
# After PR #482, client is an OpenAI instance
mock_client = MagicMock(spec=OpenAI)
provider.client = mock_client
return provider
def test_generate_typed_instructor_openai_isinstance_check(self):
"""After PR #482, client is OpenAI, so instructor path must use from_openai."""
from openai import OpenAI
from semantica.semantic_extract.providers import DeepSeekProvider
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
provider.client = MagicMock(spec=OpenAI)
self.assertIsInstance(
provider.client, OpenAI,
"client must be OpenAI instance for instructor isinstance check to pass",
)
class TestVerboseModeAssignment(unittest.TestCase):
"""Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
def _make_openai_provider(self):
from semantica.semantic_extract.providers import OpenAIProvider
with patch.object(OpenAIProvider, "_init_client", return_value=None):
provider = OpenAIProvider(api_key="sk-test")
provider.client = MagicMock()
return provider
def test_generate_typed_no_verbose_no_name_error(self):
"""generate_typed must not raise NameError for verbose_mode when verbose not passed."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_instructor = MagicMock()
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = Schema(value="ok")
mock_instructor.from_openai.return_value = mock_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
try:
result = provider.generate_typed("prompt", Schema)
except NameError as e:
self.fail(f"NameError for verbose_mode: {e}")
except Exception:
pass # other errors are OK — we only care NameError is gone
def test_generate_typed_verbose_true_prints(self):
"""When verbose=True, generate_typed must print the confirmation line."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema, verbose=True)
except Exception:
pass
output = captured.getvalue()
# verbose_mode=True should trigger the print statement
self.assertIn("generate_typed", output)
def test_generate_typed_verbose_false_no_print(self):
"""When verbose=False (default), generate_typed must not print anything."""
provider = self._make_openai_provider()
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema)
except Exception:
pass
self.assertEqual(captured.getvalue(), "")
def test_generate_typed_verbose_from_config(self):
"""verbose_mode must also respect config-level verbose setting."""
provider = self._make_openai_provider()
provider.config["verbose"] = True
class Schema(BaseModel):
value: str
mock_schema_instance = Schema(value="ok")
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("skip")
mock_instructor.Mode.TOOLS = "tools"
import io
captured = io.StringIO()
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
with patch("sys.stdout", captured):
try:
provider.generate_typed("prompt", Schema)
except Exception:
pass
self.assertIn("generate_typed", captured.getvalue())
class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
"""Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
"""generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
from semantica.semantic_extract.providers import DeepSeekProvider
from openai import OpenAI
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
provider = DeepSeekProvider(api_key="sk-test")
mock_openai_client = MagicMock(spec=OpenAI)
provider.client = mock_openai_client
class Schema(BaseModel):
label: str
mock_instructor = MagicMock()
mock_ic_client = MagicMock()
mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
mock_instructor.from_openai.return_value = mock_ic_client
mock_instructor.from_provider.side_effect = Exception("no from_provider")
mock_instructor.Mode.JSON = "json"
mock_instructor.Mode.TOOLS = "tools"
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
result = provider.generate_typed("classify this", Schema)
# Must have called from_openai with the existing client (not a fresh one)
mock_instructor.from_openai.assert_called_once_with(
mock_openai_client, mode="json"
)
self.assertEqual(result.label, "ok")
if __name__ == "__main__":
unittest.main()