mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa74c6a8c | ||
|
|
ca5f081793 | ||
|
|
6ad1502224 | ||
|
|
b010ba68fa | ||
|
|
7c8dfbd3c0 | ||
|
|
45400c88d3 | ||
|
|
26f6cdf9e5 | ||
|
|
96f88594c2 | ||
|
|
c0d08c46f7 | ||
|
|
5a388d0bcc | ||
|
|
f516aef8fd | ||
|
|
c9a382e676 | ||
|
|
897d950bdc | ||
|
|
dd8fa17db8 | ||
|
|
92801c220e | ||
|
|
738480606c | ||
|
|
f9e0bcf210 | ||
|
|
bb0e9f49e3 | ||
|
|
f95c1612d5 | ||
|
|
8c202a691e | ||
|
|
304b82fbd6 | ||
|
|
8d2dfaa53c | ||
|
|
39aaae778f | ||
|
|
16d628997a | ||
|
|
5e6ad6e87e | ||
|
|
f6198039fa | ||
|
|
983f5301e8 | ||
|
|
5a852169be | ||
|
|
fe6ca7fccb | ||
|
|
66c8431eee | ||
|
|
3e2a0a3f3b | ||
|
|
d22a54353a | ||
|
|
f165679c11 | ||
|
|
17460edca9 | ||
|
|
7e815920ac | ||
|
|
7f93eb7104 | ||
|
|
eec3e8804a | ||
|
|
9cb6073568 | ||
|
|
073c48882c | ||
|
|
be86d1b5db | ||
|
|
6f93f429c4 | ||
|
|
bc683e7a34 | ||
|
|
cda5310949 | ||
|
|
17f88ca600 | ||
|
|
658de23357 | ||
|
|
66e8964d22 | ||
|
|
892ff4b4a7 | ||
|
|
a88300d74f | ||
|
|
390152c78c | ||
|
|
17602812f9 | ||
|
|
523b02083f | ||
|
|
952a4530f5 |
@@ -63,7 +63,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
|
||||
@@ -111,5 +111,12 @@ sample_data/
|
||||
# Test Results
|
||||
test_results.txt
|
||||
|
||||
# Frontend workspace artifacts
|
||||
semantica-explorer/
|
||||
node_modules/
|
||||
|
||||
# Frontend build artifacts (generated by Vite — do not track in git)
|
||||
semantica/static/
|
||||
|
||||
# Local graph explorer test datasets
|
||||
demo_out/
|
||||
|
||||
+50
-1
@@ -7,8 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||
- parallel-edge aggregation in full view
|
||||
- collapse behavior preserving active-path neighbors
|
||||
- grouped community-node/community-edge projection behavior
|
||||
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||
|
||||
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
|
||||
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
|
||||
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
|
||||
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
|
||||
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
|
||||
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
|
||||
|
||||
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 24–471 ms; a session with 500 k nodes was effectively unusable.
|
||||
- **New `semantica/explorer/search_index.py`**: Purpose-built in-memory inverted index with three lookup tiers — exact-term index (full normalized strings), token index (individual words), and prefix index (2–12 character prefixes of every token). A linear secondary-scan fallback (capped at 12 k nodes) handles queries that miss all three tiers. An LRU result cache (128 slots, `OrderedDict`) serves repeat queries at zero cost. Warm query times on the same 118 k-node graph: 24 ms → 0.004 ms (exact), 471 ms → 0.009 ms (ID lookup), 475 ms → 0.002 ms (no-match).
|
||||
- **`IndexedNodeDocument`** frozen dataclass stores per-node primary text (ID, content, curated alias keys: `label`, `name`, `pref_label`, `aliases`, `synonyms`, `display_name`, etc.), secondary text (remaining properties), token set, prefix expansions, confidence, and tags. Primary text prioritizes human-readable fields; secondary text covers the full property bag up to a 48-fragment cap.
|
||||
- **Scoring**: exact ID match → 140, exact term → 120, primary-text substring → 78 + length bonus, token hit → 18, prefix hit → 10, multi-token bonus → 4 per hit. Ties broken deterministically on `(score, exactness, token_hits, node_id)`.
|
||||
- **Mutation sync**: `GraphSession.add_node()`, `add_nodes()`, `add_edges()` and `add_node()` update the index incrementally. `handle_graph_mutation()` integrates with the WebSocket mutation bridge for live updates during reasoning, enrichment, and remote graph changes. `rebuild_search_index()` performs a full O(n) rebuild when needed (session init, merge, reload). `enrich.py` routes node/edge additions through `session.add_node`/`session.add_edge` so reasoning-inferred nodes are indexed immediately.
|
||||
- **Review fixes applied**: replaced `list.sort()` per upsert with `bisect.insort()` (O(log n) vs O(n log n)); replaced `list.remove()` with `bisect.bisect_left` + `pop()` (O(log n) vs O(n)); added `with self._lock` in `handle_graph_mutation()` to prevent index races from the WebSocket thread; removed unnecessary source/target upserts on `add_edge()` (edges don't affect node text); sorted tag values in `_cache_key()` so `["a","b"]` and `["b","a"]` share a cache entry.
|
||||
- **Follow-up fix by @KaifAhmad1 and @ZohaibHassan16**: restored `_ordered_node_ids` maintenance during upserts so `secondary_scan` fallback works for terms that are only present in non-curated properties; added regression test coverage to lock this behavior.
|
||||
- 3 new tests: `test_search_exact_and_prefix` (exact match + prefix match), `test_search_filters_and_cache_stability` (type + confidence filter, identical repeated requests), `test_search_sees_new_nodes_after_mutation` (node added via `session.add_node()` immediately visible in search).
|
||||
- 1 additional regression test: `test_search_secondary_scan_fallback_matches_non_curated_properties` (verifies fallback matching when a query term appears only in non-curated properties).
|
||||
- **Fix: Provenance traversal now includes multi-hop upstream ancestors + edge direction classification** (closes #470, PR #480 by @Sameer6305, review fixes by @KaifAhmad1):
|
||||
- **Bug — upstream ancestors silently excluded**: `_build_provenance()` built a directed `nx.DiGraph` and seeded first-hop neighbors correctly, but the final subgraph extraction called `nx.ego_graph(..., undirected=False)`. With directed traversal, ego-graph expansion only follows outgoing edges from the focus node, so any node that *points into* the focus node (i.e. an upstream ancestor at depth ≥ 2) was invisible. For the chain `Source → Intermediate → node_id`, `Intermediate` appeared at hop 1 but `Source` was silently dropped. Fixed by changing to `undirected=True` — the radius expansion now traverses both incoming and outgoing edges while the underlying `DiGraph` is preserved, so edge source/target semantics remain correct.
|
||||
- **Enhancement — edge direction classification**: `ProvenanceEdge` gains a `direction: str` field. Each edge in the provenance subgraph is classified relative to the focus node: `"upstream"` when `target == node_id` (edge flows into the focus node), `"downstream"` when `source == node_id` (edge flows out), and `"lateral"` for all other edges between non-focus neighbors. This lets consumers distinguish ancestor provenance from descendant impact without re-traversing the graph.
|
||||
- **Enhancement — grouped markdown report**: `_render_markdown()` now groups lineage edges under separate `## Upstream`, `## Downstream`, and `## Lateral` sections instead of a flat `## Lineage Edges` list. Empty sections are omitted. This improves readability of exported provenance reports.
|
||||
- **Schema consolidation**: `ProvenanceNode`, `ProvenanceEdge`, and `ProvenanceResponse` moved from inline definitions in `routes/provenance.py` to the shared `semantica/explorer/schemas.py`, matching the convention used by all other Explorer routes. `ProvenanceNode.parent_id` is now `Optional[str] = None`.
|
||||
- **Merge conflicts resolved**: Resolved all conflict markers in `provenance.py`, `app.py`, and `.gitignore`; restored the complete router import set in `app.py` (`graph`, `sparql`, `temporal`, `vocabulary`) that the conflict had dropped.
|
||||
- 2 new tests in `tests/explorer/test_provenance_route.py`: `test_build_provenance_direction_classification_chain` (asserts `Source` and `Intermediate` both appear for `Source → Intermediate → node_id`; verifies `Intermediate → node_id` classified as `"upstream"`) and `test_render_markdown_groups_edges_by_direction` (asserts grouped section headings and correct edge lines in output).
|
||||
|
||||
- **Fix: `OWLExporter._export_owl_turtle` invalid Turtle syntax and silent data-property omission** (closes #478 by @KaifAhmad1):
|
||||
- **Bug 1 — Invalid Turtle syntax**: `_export_owl_turtle` unconditionally wrote `rdfs:label` with a closing period (`.`), then appended `rdfs:subClassOf`, `rdfs:domain`, and `rdfs:range` triples after the closed block. Any RDF parser would reject the output. Fixed by introducing `_ttl_block(subject_uri, rdf_type, predicates)` — all predicate-object pairs for a subject are accumulated first, then joined with ` ;\n ` and terminated with a single ` .`, producing valid Turtle in all cases.
|
||||
- **Bug 2 — Data properties silently dropped**: `_export_owl_turtle` had loops for `classes` and `object_properties` but no loop for `data_properties`, so all `owl:DatatypeProperty` declarations were silently omitted. Added the missing loop, mirroring the existing object-property loop.
|
||||
- **String escaping**: User-provided strings (`name`, `description`, `comment`, version) were embedded directly into Turtle string literals without escaping. A class named `John"s Class` or a comment containing a backslash or newline produced unparseable output. Added `_escape_ttl_str()` static method (escapes `"`, `\`, `\n`, `\r`, `\t`) applied at every `rdfs:label`, `rdfs:comment`, and `owl:versionInfo` site.
|
||||
- **Null-check consistency**: All optional field reads now use `x = prop.get("field"); if x:` uniformly — eliminates the mixed pattern of `.get()` guards followed by direct `[]` access.
|
||||
- 43 tests added in `tests/export/test_owl_exporter.py` across five suites: `TestTurtleSyntaxValidity` (5), `TestDataPropertiesInTurtle` (8), `TestTurtleHeader` (4), `TestTurtleStringEscaping` (16), `TestNullFieldHandling` (7), plus `TestObjectPropertyListDomainRange` (2) and `TestEquivalentClass` (1).
|
||||
|
||||
- **Enhancement: Node distance semantics in path responses** (closes #472 by @KaifAhmad1): `PathResponse` now surfaces two new first-class fields — `hop_count: int` (equal to `len(path) - 1`; `0` for self-paths) and `distance_band: str` — so callers no longer need to count hops or implement band classification themselves. Four bands are defined: `"direct"` (0–1 hops), `"near"` (2–3), `"mid-range"` (4–6), `"distant"` (7+). The classification function `classify_path_distance()` lives in `semantica/utils/helpers.py` as the single source of truth; both the Explorer route and the visualizer import from it. `KGVisualizer.visualize_network()` gains an optional `highlight_path: list[str]` parameter: when provided, path edges are rendered as a separate orange trace with opacity and stroke width scaled to the distance band (direct: 1.0 / 4 px → distant: 0.35 / 1.5 px), while non-path edges render at reduced opacity underneath. Edge direction is respected — only the forward pairs `(A, B)` along the path are matched; reverse back-edges in directed graphs are not incorrectly highlighted. A logger warning is emitted when any node ID in `highlight_path` has no layout position, surfacing silent no-op mismatches. Frontend `PathResponse` type in `GraphInspectorPanel.tsx` and `GraphWorkspaceShell.tsx` extended with `hop_count: number` and `distance_band: "direct" | "near" | "mid-range" | "distant"`. All changes are additive; no existing fields removed. 10 new tests: 2 API-level (`test_response_includes_hop_count_and_distance_band`, `test_one_hop_path_is_direct`) and 8 unit tests covering all four band boundaries (0, 1, 2, 3, 4, 6, 7, 20 hops).
|
||||
|
||||
- **Enhancement: Bidirectional path finding in Knowledge Explorer** (closes #469 by @KaifAhmad1): Path queries in the Explorer were direction-sensitive — querying B→A when only the edge A→B existed always returned no result, because `PathFinder._get_neighbors()` called `graph.neighbors(node)` which on a `nx.DiGraph` yields only successors. Added a `directed: bool = True` parameter to `bfs_shortest_path()` and `dijkstra_shortest_path()`. When `directed=False` a lightweight undirected view is built via `graph.to_undirected()` for the traversal pass only; the original directed edges are preserved and returned in the response. A `_make_undirected_view()` helper encapsulates the conversion and falls back safely for non-NetworkX graph types. The `/api/graph/node/{id}/path` route exposes the parameter as a query string flag (`?directed=false`); `PathResponse` gains a `directed: bool` field that echoes the mode used. Default is `True`, so all existing callers are unaffected. The route also gained an empty-path 404 guard — previously a traversal that found no path returned `200` with `path: []` instead of `404`. 21 new tests: 12 unit tests in `TestBidirectionalPathFinding` (`tests/kg/test_path_finder.py`) and 9 API-level tests in `TestBidirectionalPathRoute` (`tests/explorer/test_explorer_api.py`).
|
||||
|
||||
- **Enhancement: Native `KnowledgeGraph` type support in `KGVisualizer`** (PR `kg` by @KaifAhmad1, closes #471): Added `semantica/kg/knowledge_graph.py` — a formal `KnowledgeGraph` dataclass (`entities`, `relationships`, `metadata`) that is now the canonical in-memory type produced and consumed by the Semantica KG pipeline. Exported from `semantica.kg`. `KGVisualizer` gains `_convert_knowledge_graph()` — an authoritative, non-mutating conversion path from `KnowledgeGraph` to the internal dict format — and `_normalize_graph()` now routes `isinstance(graph, KnowledgeGraph)` through it as an explicit fast-path before duck-typing. All five public entry points (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) accept `KnowledgeGraph` directly; no manual conversion required. All existing callers passing dicts or duck-typed objects are unaffected. 15 new tests in `TestFormalKnowledgeGraphType` (conversion shape, non-mutation, determinism, routing, all five entry points, import availability).
|
||||
- **Fix: `KGVisualizer` now accepts `KnowledgeGraph` objects in all `visualize_*` methods** (PR `visualization` by @KaifAhmad1, closes #458): All five public methods (`visualize_network`, `visualize_communities`, `visualize_centrality`, `visualize_entity_types`, `visualize_relationship_matrix`) previously called `graph.get("entities", [])`, silently producing no output when passed a non-dict object. Added `_normalize_graph()` which duck-types the input — dicts pass through unchanged; any object exposing `.entities` / `.relationships` attributes (e.g. the result of `GraphBuilder.build()`) is converted to the canonical dict form; anything else raises a clear `ProcessingError` naming the offending type. 21 tests added in `tests/visualization/test_kg_visualizer_normalize_graph.py`.
|
||||
|
||||
- **Security: 12 vulnerability fixes across CRITICAL → LOW severity** (PR `security-enhancement` by @KaifAhmad1):
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
FROM node:25-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app/semantica-explorer
|
||||
|
||||
@@ -13,7 +13,7 @@ COPY semantica-explorer/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.12-slim AS runtime
|
||||
FROM python:3.14-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+855
-313
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs"
|
||||
"test:graph-store": "node --test tests/graphStore.multi-edge.test.mjs",
|
||||
"test:graph-workspace": "node --import tsx --test tests/graphSceneState.display.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^5.4.0"
|
||||
|
||||
+28
-3
@@ -15,7 +15,7 @@ const EntityResolutionTab = lazy(() => import('./workspaces/EnrichWorkspace/Enti
|
||||
const KGOverviewTab = lazy(() => import('./workspaces/ManageWorkspace/KGOverviewTab').then((module) => ({ default: module.KGOverviewTab })));
|
||||
const OntologySummaryTab = lazy(() => import('./workspaces/ManageWorkspace/OntologySummaryTab').then((module) => ({ default: module.OntologySummaryTab })));
|
||||
|
||||
type WorkspaceId = 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type WorkspaceId = 'welcome' | 'explore' | 'analyze' | 'decisions' | 'enrich' | 'manage';
|
||||
type ExploreView = 'graph' | 'vocabulary';
|
||||
type AnalyzeView = 'sparql' | 'reasoning';
|
||||
type EnrichView = 'import' | 'merge' | 'registry' | 'resolve';
|
||||
@@ -311,14 +311,39 @@ function WorkspaceFallback() {
|
||||
return <div className="workspace-loading">Loading workspace…</div>;
|
||||
}
|
||||
|
||||
function WelcomeScreen() {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700, color: 'var(--text-main)', letterSpacing: '-0.03em' }}>
|
||||
Welcome to Semantica
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: 14 }}>
|
||||
Select a workspace from the sidebar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('explore');
|
||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceId>('welcome');
|
||||
const [exploreView, setExploreView] = useState<ExploreView>('graph');
|
||||
const [analyzeView, setAnalyzeView] = useState<AnalyzeView>('reasoning');
|
||||
const [enrichView, setEnrichView] = useState<EnrichView>('import');
|
||||
const [manageView, setManageView] = useState<ManageView>('lineage');
|
||||
|
||||
const renderWorkspace = () => {
|
||||
if (activeWorkspace === 'welcome') {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
|
||||
if (activeWorkspace === 'explore') {
|
||||
return (
|
||||
<WorkspaceShell
|
||||
@@ -451,7 +476,7 @@ export default function App() {
|
||||
<style>{shellStyles}</style>
|
||||
<div className="app-shell">
|
||||
<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 }) => (
|
||||
<button
|
||||
key={id}
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface NodeAttributes {
|
||||
haloColor?: string;
|
||||
labelVisibilityPolicy?: GraphLabelVisibilityPolicy;
|
||||
highlighted?: boolean;
|
||||
communityId?: string;
|
||||
isCommunityGroup?: boolean;
|
||||
memberCount?: number;
|
||||
anchorNodeId?: string | null;
|
||||
|
||||
nodeType: string;
|
||||
content: string;
|
||||
@@ -74,6 +78,12 @@ export interface EdgeAttributes {
|
||||
parallelIndex?: number;
|
||||
parallelCount?: number;
|
||||
familySize?: number;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
|
||||
|
||||
edgeType: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
@@ -14,10 +15,16 @@ export type PathResponse = {
|
||||
path: string[];
|
||||
edge_ids?: string[];
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
inspectableNodeId?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
canActivateFocused?: boolean;
|
||||
focusedUnavailableReason?: string | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
@@ -146,6 +153,10 @@ function PathFlowViz({
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
inspectableNodeId,
|
||||
selectedNodeKind = "none",
|
||||
canActivateFocused = false,
|
||||
focusedUnavailableReason = null,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
@@ -171,7 +182,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;
|
||||
content?: string;
|
||||
label?: string;
|
||||
@@ -194,12 +236,26 @@ export function GraphInspectorPanel({
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>{attributes?.nodeType || "Entity"}</span>
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? nodeId)}
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</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 }}>
|
||||
{attributes?.valid_from || attributes?.valid_until ? (
|
||||
<span style={subtleChipStyle}>temporal</span>
|
||||
@@ -224,7 +280,7 @@ export function GraphInspectorPanel({
|
||||
<button
|
||||
style={{ ...actionButtonStyle, width: "100%", justifyContent: "center", opacity: isRunningPredictions ? 0.7 : 1 }}
|
||||
onClick={onRunPredictions}
|
||||
disabled={isRunningPredictions}
|
||||
disabled={isRunningPredictions || !actionNodeId}
|
||||
>
|
||||
{isRunningPredictions ? (
|
||||
<Loader2 size={14} className="animate-spin" style={{ marginRight: 6 }} />
|
||||
@@ -232,10 +288,10 @@ export function GraphInspectorPanel({
|
||||
{isRunningPredictions ? "Running…" : "Run Link Prediction"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")} disabled={!actionNodeId}>
|
||||
Provenance JSON
|
||||
</button>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>
|
||||
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")} disabled={!actionNodeId}>
|
||||
Provenance MD
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,7 +313,7 @@ export function GraphInspectorPanel({
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
@@ -376,6 +432,14 @@ const inputStyle: CSSProperties = {
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import { resolveDisplayGraph } from "./graphSceneState";
|
||||
import {
|
||||
chooseColorAccessor,
|
||||
colorForNodeKey,
|
||||
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
const EMPTY_PATH: string[] = [];
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
@@ -67,6 +69,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
visibleNeighborCount: graph.neighbors(nodeId).length,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
|
||||
const displayResult = useMemo(
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
|
||||
[activePath, graphVersion, selectedNodeId, viewMode],
|
||||
);
|
||||
|
||||
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
|
||||
|
||||
@@ -447,9 +457,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
<SigmaSceneAdapter
|
||||
ref={sceneRef}
|
||||
onNodeSelect={onNodeSelect}
|
||||
graphVersion={graphVersion}
|
||||
graphReady={Boolean(snapshot)}
|
||||
displayGraph={displayResult.graph}
|
||||
displayMeta={displayResult.meta}
|
||||
displayState={displayResult.state}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
||||
activePath={activePath}
|
||||
activePathEdgeIds={EMPTY_PATH}
|
||||
effectsState={STAGE_EFFECTS_STATE}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type Graph from "graphology";
|
||||
import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
|
||||
import { logEvent } from "../../store/registryStore";
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
@@ -11,6 +12,7 @@ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
|
||||
import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
|
||||
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { checkGroupedViewAvailability, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot } from "./graphSceneState";
|
||||
import {
|
||||
type GraphPlugin,
|
||||
type GraphPluginActionRequest,
|
||||
@@ -23,6 +25,7 @@ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
|
||||
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectToggle,
|
||||
GraphEffectsState,
|
||||
@@ -30,6 +33,7 @@ import type {
|
||||
GraphLoadProgress,
|
||||
GraphLoadSummary,
|
||||
GraphSelectedEdgeState,
|
||||
GraphSelectedNodeKind,
|
||||
GraphSelectedNodeState,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
@@ -89,7 +93,7 @@ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
|
||||
pathFlowEnabled: false,
|
||||
lensEnabled: false,
|
||||
temporalEmphasisEnabled: false,
|
||||
semanticRegionsEnabled: true,
|
||||
semanticRegionsEnabled: false,
|
||||
contoursEnabled: false,
|
||||
pathfindingEnabled: false,
|
||||
communitiesEnabled: false,
|
||||
@@ -105,6 +109,15 @@ const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then(
|
||||
const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
|
||||
const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
|
||||
const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
|
||||
const EMPTY_PATH: string[] = [];
|
||||
const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV;
|
||||
|
||||
function debugGraphWorkspace(message: string, payload?: Record<string, unknown>) {
|
||||
if (!DEBUG_GRAPH_WORKSPACE) {
|
||||
return;
|
||||
}
|
||||
console.debug(`[GraphWorkspace] ${message}`, payload ?? {});
|
||||
}
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
@@ -494,7 +507,10 @@ function buildRealtimeEdgeAttributes(payload: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
function buildSelectedNodeState(
|
||||
nodeId: string,
|
||||
displayState: GraphDisplayStateSnapshot,
|
||||
): GraphSelectedNodeState | null {
|
||||
if (!nodeId || !graph.hasNode(nodeId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -519,31 +535,64 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
visibleNeighborCount: displayState.selectedVisibleNeighborIds.length,
|
||||
collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length,
|
||||
isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
if (!edgeId || !graph.hasEdge(edgeId)) {
|
||||
type FocusResolution = {
|
||||
kind: GraphSelectedNodeKind;
|
||||
resolvedNodeId: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
function buildSelectedEdgeState(
|
||||
edgeId: string,
|
||||
displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
|
||||
): GraphSelectedEdgeState | null {
|
||||
if (!edgeId || !displayGraph.hasEdge(edgeId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [sourceId, targetId] = graph.extremities(edgeId);
|
||||
const attributes = graph.getEdgeAttributes(edgeId) as {
|
||||
const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
|
||||
const attributes = displayGraph.getEdgeAttributes(edgeId) as {
|
||||
edgeType?: string;
|
||||
weight?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
familyId?: string;
|
||||
rawEdgeIds?: string[];
|
||||
isAggregated?: boolean;
|
||||
aggregateCount?: number;
|
||||
bundleKind?: "parallel" | "bidirectional" | "community";
|
||||
dominantEdgeType?: string;
|
||||
representativeWeight?: number;
|
||||
};
|
||||
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
|
||||
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
|
||||
const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId];
|
||||
const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0];
|
||||
let sourceId = displaySourceId;
|
||||
let targetId = displayTargetId;
|
||||
if (primaryRawEdgeId && graph.hasEdge(primaryRawEdgeId)) {
|
||||
[sourceId, targetId] = graph.extremities(primaryRawEdgeId);
|
||||
}
|
||||
const sourceAttributes = graph.hasNode(sourceId)
|
||||
? (graph.getNodeAttributes(sourceId) as { label?: string; content?: string })
|
||||
: ({ label: displaySourceId } as { label?: string; content?: string });
|
||||
const targetAttributes = graph.hasNode(targetId)
|
||||
? (graph.getNodeAttributes(targetId) as { label?: string; content?: string })
|
||||
: ({ label: displayTargetId } as { label?: string; content?: string });
|
||||
const properties = attributes.properties ?? {};
|
||||
const familyId = String(attributes.familyId || edgeId);
|
||||
let familySize = 0;
|
||||
let siblingCount = 0;
|
||||
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
|
||||
const edgeAttrs = candidateAttrs as { familyId?: string };
|
||||
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
|
||||
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
|
||||
rawEdgeIds.forEach((rawEdgeId) => {
|
||||
if (!graph.hasEdge(rawEdgeId)) {
|
||||
return;
|
||||
}
|
||||
const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string };
|
||||
const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId);
|
||||
if (String(candidateAttrs.familyId || rawEdgeId) === familyId) {
|
||||
familySize += 1;
|
||||
}
|
||||
if (candidateSource === sourceId && candidateTarget === targetId) {
|
||||
@@ -551,6 +600,11 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
}
|
||||
});
|
||||
|
||||
if (attributes.isAggregated) {
|
||||
siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
|
||||
familySize = Math.max(familySize, siblingCount);
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeId,
|
||||
familyId,
|
||||
@@ -564,6 +618,12 @@ function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
|
||||
provenanceCount: getProvenanceCount(properties),
|
||||
familySize,
|
||||
siblingCount,
|
||||
isAggregated: Boolean(attributes.isAggregated),
|
||||
aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length),
|
||||
rawEdgeIds,
|
||||
bundleKind: attributes.bundleKind ?? null,
|
||||
dominantEdgeType: attributes.dominantEdgeType ?? null,
|
||||
representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,9 +684,15 @@ function collectPluginOverlays(
|
||||
|
||||
export function GraphWorkspace() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState("");
|
||||
const [focusedNodeId, setFocusedNodeId] = useState("");
|
||||
const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState("");
|
||||
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
|
||||
const [graphReady, setGraphReady] = useState(false);
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
||||
const [aggregationEnabled] = useState(true);
|
||||
const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
@@ -663,15 +729,24 @@ export function GraphWorkspace() {
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "focused",
|
||||
viewMode: "full",
|
||||
zoomTier: "overview",
|
||||
isLayoutRunning: false,
|
||||
});
|
||||
const reload = useReloadGraph();
|
||||
|
||||
const handleLoadProgress = useCallback((progress: GraphLoadProgress) => {
|
||||
setLoadingProgress(progress);
|
||||
if (progress.phase !== "ready" && progress.phase !== "stabilizing_layout") {
|
||||
setGraphReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data: summary, isLoading, isFetching } = useLoadGraph({
|
||||
enabled: true,
|
||||
onGraphReady: (graphSummary) => {
|
||||
setGraphReady(true);
|
||||
setGraphVersion((current) => current + 1);
|
||||
setIsLayoutRunning(!graphSummary.layoutReady);
|
||||
if (settlingOverlayTimeoutRef.current !== null) {
|
||||
window.clearTimeout(settlingOverlayTimeoutRef.current);
|
||||
@@ -700,7 +775,7 @@ export function GraphWorkspace() {
|
||||
settlingOverlayTimeoutRef.current = null;
|
||||
}, 900);
|
||||
},
|
||||
onProgress: setLoadingProgress,
|
||||
onProgress: handleLoadProgress,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -762,6 +837,7 @@ export function GraphWorkspace() {
|
||||
});
|
||||
prevActiveIdsRef.current = nextActiveIds;
|
||||
setActiveNodeCount(data.active_node_count);
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
});
|
||||
} catch (fetchError) {
|
||||
@@ -777,16 +853,166 @@ export function GraphWorkspace() {
|
||||
};
|
||||
}, [debouncedTime, isLoading]);
|
||||
|
||||
const resolveNodeIdForFocusedMode = useCallback((
|
||||
nodeId: string,
|
||||
displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null,
|
||||
): FocusResolution => {
|
||||
if (!nodeId) {
|
||||
return {
|
||||
kind: "none",
|
||||
resolvedNodeId: null,
|
||||
reason: "Select a node to inspect in Focused mode.",
|
||||
};
|
||||
}
|
||||
|
||||
if (graph.hasNode(nodeId)) {
|
||||
return {
|
||||
kind: "base",
|
||||
resolvedNodeId: nodeId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
if (currentDisplayGraph.hasNode(nodeId)) {
|
||||
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
const communityGroup = displayAttrs.properties?.__communityGroup as
|
||||
| {
|
||||
anchorNodeId?: string | null;
|
||||
sampleNodeIds?: string[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
|
||||
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
|
||||
return {
|
||||
kind: "grouped",
|
||||
resolvedNodeId: anchorNodeId,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "grouped",
|
||||
resolvedNodeId: null,
|
||||
reason: "Focused mode is unavailable for this grouped selection.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unavailable",
|
||||
resolvedNodeId: null,
|
||||
reason: "Selected item is not available in the current graph.",
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focusedSelectionResolution = useMemo(
|
||||
() => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph),
|
||||
[pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode],
|
||||
);
|
||||
const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? "";
|
||||
const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId);
|
||||
const { available: groupedViewAvailable, reason: groupedViewReason } = useMemo(
|
||||
() => checkGroupedViewAvailability(),
|
||||
[graphVersion],
|
||||
);
|
||||
const groupedDisplayCandidate = useMemo(
|
||||
() => viewMode === "grouped"
|
||||
? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
: null,
|
||||
[viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
|
||||
);
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode === "focused") {
|
||||
const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph);
|
||||
if (!resolution.resolvedNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedNodeId(resolution.resolvedNodeId);
|
||||
setSelectedNodeId(resolution.resolvedNodeId);
|
||||
setViewMode("focused");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextViewMode === "grouped") {
|
||||
if (!groupedViewAvailable) {
|
||||
debugGraphWorkspace("grouped-view-unavailable", {
|
||||
reason: groupedViewReason,
|
||||
graphVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const groupedDisplayGraph = groupedDisplayCandidate?.graph
|
||||
?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}).graph;
|
||||
const nextGroupedSelection = [
|
||||
lastGroupedSelectedNodeId,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
]
|
||||
.map((candidateId) => resolveGroupedDisplayNodeId(groupedDisplayGraph, candidateId))
|
||||
.find((candidateId): candidateId is string => Boolean(candidateId))
|
||||
?? "";
|
||||
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId(nextGroupedSelection);
|
||||
if (nextGroupedSelection) {
|
||||
setLastGroupedSelectedNodeId(nextGroupedSelection);
|
||||
}
|
||||
setViewMode("grouped");
|
||||
setIsLayoutRunning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId((currentSelectedNodeId) => (
|
||||
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
||||
));
|
||||
setViewMode("full");
|
||||
}, [
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
focusedNodeId,
|
||||
graphVersion,
|
||||
groupedDisplayCandidate,
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
lastGroupedSelectedNodeId,
|
||||
resolveNodeIdForFocusedMode,
|
||||
selectedNodeId,
|
||||
]);
|
||||
|
||||
const focusNode = useCallback((nodeId: string) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
if (!nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
|
||||
const nextSelectedNodeId = nodeId;
|
||||
|
||||
if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
|
||||
setLastGroupedSelectedNodeId(nodeId);
|
||||
}
|
||||
|
||||
setSelectedNodeId(nextSelectedNodeId);
|
||||
setSelectedEdgeId("");
|
||||
setPathResult(null);
|
||||
setSearchResults([]);
|
||||
setSearchError("");
|
||||
if (nodeId) {
|
||||
if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
|
||||
setFocusedNodeId(nextSelectedNodeId);
|
||||
setIsLayoutRunning(false);
|
||||
}
|
||||
}, []);
|
||||
}, [viewMode]);
|
||||
|
||||
const handleEdgeSelect = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
@@ -815,14 +1041,14 @@ export function GraphWorkspace() {
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleRunPredictions = useCallback(async () => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
setIsRunningPredictions(true);
|
||||
try {
|
||||
const response = await fetch("/api/enrich/links", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: selectedNodeId,
|
||||
node_id: inspectableNodeId,
|
||||
top_n: 6,
|
||||
candidate_type: predictionType || undefined,
|
||||
min_score: 0,
|
||||
@@ -839,13 +1065,13 @@ export function GraphWorkspace() {
|
||||
} finally {
|
||||
setIsRunningPredictions(false);
|
||||
}
|
||||
}, [predictionType, selectedNodeId]);
|
||||
}, [inspectableNodeId, predictionType]);
|
||||
|
||||
const handleTracePath = useCallback(async () => {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
if (!inspectableNodeId || !pathTargetId.trim()) return;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
`/api/graph/node/${encodeURIComponent(inspectableNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
@@ -862,12 +1088,12 @@ export function GraphWorkspace() {
|
||||
console.error("[GraphWorkspace] path trace failed", pathError);
|
||||
setPathResult(null);
|
||||
}
|
||||
}, [pathTargetId, selectedNodeId]);
|
||||
}, [inspectableNodeId, pathTargetId]);
|
||||
|
||||
const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
|
||||
if (!selectedNodeId) return;
|
||||
if (!inspectableNodeId) return;
|
||||
const suffix = format === "markdown" ? "markdown" : "json";
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&format=${suffix}`);
|
||||
const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provenance report failed with status ${response.status}`);
|
||||
}
|
||||
@@ -875,12 +1101,12 @@ export function GraphWorkspace() {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor);
|
||||
}, [selectedNodeId]);
|
||||
}, [inspectableNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -905,6 +1131,7 @@ export function GraphWorkspace() {
|
||||
},
|
||||
]);
|
||||
logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
if (eventType === "ADD_EDGE") {
|
||||
@@ -918,6 +1145,7 @@ export function GraphWorkspace() {
|
||||
},
|
||||
]);
|
||||
logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id} → ${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
|
||||
setGraphVersion((current) => current + 1);
|
||||
sceneRef.current?.getRuntime()?.requestRender();
|
||||
}
|
||||
} catch (socketError) {
|
||||
@@ -930,32 +1158,163 @@ export function GraphWorkspace() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedNeighborhoodNodeIds([]);
|
||||
setFocusedNodeId("");
|
||||
setLastGroupedSelectedNodeId("");
|
||||
}, [summary?.edgeCount, summary?.nodeCount]);
|
||||
|
||||
const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress));
|
||||
const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
|
||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||
const activePath = pathResult?.path ?? EMPTY_PATH;
|
||||
const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
|
||||
const structuralSelectedNodeId = useMemo(() => {
|
||||
if (viewMode === "focused") {
|
||||
return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
|
||||
}
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
return "";
|
||||
}
|
||||
return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
|
||||
}, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
|
||||
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
|
||||
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
|
||||
const displayResult = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
}))
|
||||
: resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
})
|
||||
),
|
||||
[
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedDisplayCandidate,
|
||||
structuralActivePath,
|
||||
structuralActivePathEdgeIds,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
],
|
||||
);
|
||||
const displayState = useMemo(
|
||||
() => (
|
||||
viewMode === "grouped"
|
||||
? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, {
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
selectedNodeKind: focusedSelectionResolution.kind,
|
||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
||||
})
|
||||
: resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
selectedNodeKind: focusedSelectionResolution.kind,
|
||||
resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
|
||||
focusedUnavailableReason: focusedSelectionResolution.reason,
|
||||
})
|
||||
),
|
||||
[
|
||||
activePath,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
displayResult.graph,
|
||||
focusedSelectionResolution.kind,
|
||||
focusedSelectionResolution.reason,
|
||||
focusedSelectionResolution.resolvedNodeId,
|
||||
groupedViewAvailable,
|
||||
groupedViewReason,
|
||||
selectedNodeId,
|
||||
viewMode,
|
||||
],
|
||||
);
|
||||
const displayMeta = displayResult.meta;
|
||||
useEffect(() => {
|
||||
if (viewMode === "grouped" && !groupedViewAvailable) {
|
||||
debugGraphWorkspace("grouped-view-reset-to-full", {
|
||||
reason: groupedViewReason,
|
||||
graphVersion,
|
||||
});
|
||||
setViewMode("full");
|
||||
setFocusedNodeId("");
|
||||
setSelectedNodeId((currentSelectedNodeId) => (
|
||||
currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
|
||||
));
|
||||
}
|
||||
}, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]);
|
||||
useEffect(() => {
|
||||
if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) {
|
||||
setViewMode("full");
|
||||
setFocusedNodeId("");
|
||||
}
|
||||
}, [focusedNodeId, graphVersion, viewMode]);
|
||||
const previousDisplayGraphRef = useRef(displayResult.graph);
|
||||
const previousDisplayStateRef = useRef(displayState);
|
||||
useEffect(() => {
|
||||
const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph;
|
||||
const displayStateChanged = previousDisplayStateRef.current !== displayState;
|
||||
debugGraphWorkspace("display-state-derived", {
|
||||
selectedNodeId,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
graphRebuilt,
|
||||
displayStateChanged,
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)),
|
||||
});
|
||||
previousDisplayGraphRef.current = displayResult.graph;
|
||||
previousDisplayStateRef.current = displayState;
|
||||
}, [
|
||||
aggregationEnabled,
|
||||
collapsedNeighborhoodNodeIds,
|
||||
displayResult.graph,
|
||||
displayState,
|
||||
selectedNodeId,
|
||||
structuralSelectedNodeId,
|
||||
viewMode,
|
||||
]);
|
||||
const focusedSummary = useMemo(() => {
|
||||
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
|
||||
if (viewMode === "grouped") {
|
||||
return displayState.groupedViewAvailable
|
||||
? "Communities compressed into grouped structure view"
|
||||
: (displayState.groupedViewReason ?? "Grouped view is unavailable for the current graph");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const localNeighborCount = graph.neighbors(selectedNodeId).length;
|
||||
if (viewMode === "focused") {
|
||||
const visibleNeighbors = Math.min(localNeighborCount, 16);
|
||||
const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
|
||||
return `${visibleNeighbors + 1} nodes in focused view`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [selectedNodeId, viewMode]);
|
||||
if (viewMode === "grouped") {
|
||||
return "Grouped structure view with direct community drill-in";
|
||||
}
|
||||
|
||||
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
|
||||
const hasGraphContent = Boolean(summary?.nodeCount);
|
||||
const activePath = pathResult?.path ?? [];
|
||||
const activePathEdgeIds = pathResult?.edge_ids ?? [];
|
||||
if (displayState.selectedCollapsedNeighborIds.length > 0) {
|
||||
return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
|
||||
}
|
||||
|
||||
return `${localNeighborCount} direct neighbors highlighted`;
|
||||
}, [displayState, selectedNodeId, viewMode]);
|
||||
const graphSummary = summary as GraphLoadSummary | null;
|
||||
const selectedNodeState = useMemo(
|
||||
() => buildSelectedNodeState(selectedNodeId),
|
||||
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedNodeState(selectedNodeId, displayState),
|
||||
[displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const selectedEdgeState = useMemo(
|
||||
() => buildSelectedEdgeState(selectedEdgeId),
|
||||
[selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
() => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
|
||||
[displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
|
||||
);
|
||||
const temporalState = useMemo(
|
||||
() => ({
|
||||
@@ -1061,7 +1420,21 @@ export function GraphWorkspace() {
|
||||
focusNode(action.nodeId);
|
||||
return;
|
||||
case "setViewMode":
|
||||
setViewMode(action.viewMode);
|
||||
requestViewMode(action.viewMode);
|
||||
return;
|
||||
case "collapseNeighborhood":
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setCollapsedNeighborhoodNodeIds((current) => (
|
||||
current.includes(selectedNodeId) ? current : [...current, selectedNodeId]
|
||||
));
|
||||
return;
|
||||
case "expandNeighborhood":
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setCollapsedNeighborhoodNodeIds((current) => current.filter((nodeId) => nodeId !== selectedNodeId));
|
||||
return;
|
||||
case "toggleEffect":
|
||||
setEffectToggle(action.effect, (current) => !current);
|
||||
@@ -1099,7 +1472,7 @@ export function GraphWorkspace() {
|
||||
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
|
||||
return;
|
||||
}
|
||||
}, [focusNode, setEffectToggle]);
|
||||
}, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]);
|
||||
|
||||
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
|
||||
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
|
||||
@@ -1139,9 +1512,11 @@ export function GraphWorkspace() {
|
||||
getEffectsState: () => effectsState,
|
||||
getDiagnosticsSnapshot: () => diagnosticsSnapshot,
|
||||
getAnalyticsSnapshot: () => graphAnalyticsState,
|
||||
getDisplayState: () => displayState,
|
||||
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
|
||||
dispatchAction: handlePluginAction,
|
||||
}), [
|
||||
displayState,
|
||||
graphAnalyticsState,
|
||||
diagnosticsSnapshot,
|
||||
effectsState,
|
||||
@@ -1267,23 +1642,58 @@ export function GraphWorkspace() {
|
||||
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
|
||||
const groups: GraphToolbarGroup[] = [];
|
||||
|
||||
if (selectedNodeId) {
|
||||
if (hasGraphContent) {
|
||||
groups.push({
|
||||
id: "view-mode",
|
||||
items: [
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
title: "Inspect the selected node in a focused local graph",
|
||||
active: viewMode === "focused",
|
||||
onClick: () => setViewMode("focused"),
|
||||
},
|
||||
{
|
||||
id: "view-full",
|
||||
label: "Full Graph",
|
||||
title: "Return to the full graph context",
|
||||
active: viewMode === "full",
|
||||
onClick: () => setViewMode("full"),
|
||||
onClick: () => requestViewMode("full"),
|
||||
},
|
||||
{
|
||||
id: "view-grouped",
|
||||
label: "Grouped View",
|
||||
title: displayState.groupedViewAvailable
|
||||
? "Compress dense structure into detected communities"
|
||||
: (displayState.groupedViewReason ?? "Grouped view is unavailable until communities can be detected"),
|
||||
active: viewMode === "grouped",
|
||||
disabled: !displayState.groupedViewAvailable,
|
||||
onClick: () => requestViewMode("grouped"),
|
||||
},
|
||||
{
|
||||
id: "view-focused",
|
||||
label: "Focused",
|
||||
title: canActivateFocusedMode
|
||||
? "Inspect the selected node in a focused local graph"
|
||||
: (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"),
|
||||
active: viewMode === "focused",
|
||||
disabled: viewMode !== "focused" && !canActivateFocusedMode,
|
||||
onClick: () => requestViewMode("focused"),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedNodeState) {
|
||||
groups.push({
|
||||
id: "local-structure",
|
||||
items: [
|
||||
{
|
||||
id: "collapse-neighborhood",
|
||||
label: "Collapse Neighborhood",
|
||||
title: "Hide lower-priority fanout around the selected node",
|
||||
disabled: !selectedNodeState.canCollapseNeighborhood || selectedNodeState.isNeighborhoodCollapsed,
|
||||
onClick: () => handlePluginAction({ type: "collapseNeighborhood" }),
|
||||
},
|
||||
{
|
||||
id: "expand-neighborhood",
|
||||
label: "Expand Neighborhood",
|
||||
title: "Restore the collapsed local neighborhood",
|
||||
disabled: !selectedNodeState.isNeighborhoodCollapsed,
|
||||
onClick: () => handlePluginAction({ type: "expandNeighborhood" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1304,25 +1714,13 @@ export function GraphWorkspace() {
|
||||
id: "zoom-in",
|
||||
label: "+ Zoom In",
|
||||
title: "Zoom in (or scroll up on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedZoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
onClick: () => sceneRef.current?.zoomIn(),
|
||||
},
|
||||
{
|
||||
id: "zoom-out",
|
||||
label: "- Zoom Out",
|
||||
title: "Zoom out (or scroll down on the canvas)",
|
||||
onClick: () => {
|
||||
const runtime = sceneRef.current?.getRuntime();
|
||||
if (runtime?.renderer === "sigma") {
|
||||
const camera = (runtime.scene as import("sigma").default).getCamera();
|
||||
camera.animatedUnzoom({ duration: 200 });
|
||||
}
|
||||
},
|
||||
onClick: () => sceneRef.current?.zoomOut(),
|
||||
},
|
||||
{
|
||||
id: "fit-view",
|
||||
@@ -1362,18 +1760,42 @@ export function GraphWorkspace() {
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
|
||||
}, [
|
||||
canActivateFocusedMode,
|
||||
displayState.groupedViewAvailable,
|
||||
displayState.groupedViewReason,
|
||||
handlePluginAction,
|
||||
hasGraphContent,
|
||||
isLayoutRunning,
|
||||
pluginToolbarItems,
|
||||
reload,
|
||||
requestViewMode,
|
||||
searchQuery,
|
||||
canActivateFocusedMode,
|
||||
focusedSelectionResolution.reason,
|
||||
selectedNodeId,
|
||||
selectedNodeState,
|
||||
showLoadingOverlay,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const sceneAdapterProps = {
|
||||
onNodeSelect: focusNode,
|
||||
onEdgeSelect: handleEdgeSelect,
|
||||
graphVersion,
|
||||
graphReady,
|
||||
displayGraph: displayResult.graph,
|
||||
displayMeta,
|
||||
displayState,
|
||||
selectedNodeId,
|
||||
focusedNodeId,
|
||||
selectedEdgeId,
|
||||
activePath,
|
||||
activePathEdgeIds,
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
layoutSource: graphSummary?.layoutSource,
|
||||
viewMode,
|
||||
showFitViewButton: false,
|
||||
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
|
||||
@@ -1395,7 +1817,7 @@ export function GraphWorkspace() {
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div className="explore-toolbar">
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{showLoadingOverlay && loadingProgress ? (
|
||||
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
|
||||
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
|
||||
) : null}
|
||||
{summary ? (
|
||||
@@ -1504,8 +1926,20 @@ export function GraphWorkspace() {
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
|
||||
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
|
||||
{selectedEdgeState.isAggregated ? (
|
||||
<MetricChip tone="success">
|
||||
{selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"}
|
||||
</MetricChip>
|
||||
) : (
|
||||
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
|
||||
)}
|
||||
<MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
|
||||
{selectedEdgeState.bundleKind ? (
|
||||
<MetricChip>{selectedEdgeState.bundleKind} bundle</MetricChip>
|
||||
) : null}
|
||||
{selectedEdgeState.dominantEdgeType ? (
|
||||
<MetricChip>{selectedEdgeState.dominantEdgeType}</MetricChip>
|
||||
) : null}
|
||||
{selectedEdgeState.provenanceCount > 0 ? (
|
||||
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
|
||||
) : null}
|
||||
@@ -1608,6 +2042,10 @@ export function GraphWorkspace() {
|
||||
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
|
||||
<LazyGraphInspectorPanel
|
||||
nodeId={selectedNodeId}
|
||||
inspectableNodeId={inspectableNodeId || null}
|
||||
selectedNodeKind={displayState.selectedNodeKind}
|
||||
canActivateFocused={canActivateFocusedMode}
|
||||
focusedUnavailableReason={displayState.focusedUnavailableReason}
|
||||
predictions={predictions}
|
||||
predictionType={predictionType}
|
||||
onPredictionTypeChange={setPredictionType}
|
||||
|
||||
@@ -33,6 +33,8 @@ type LinkPrediction = {
|
||||
type PathResponse = {
|
||||
path: GraphPath;
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
};
|
||||
|
||||
type TemporalBounds = {
|
||||
@@ -137,6 +139,10 @@ function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor
|
||||
valid_until: node.valid_until ?? null,
|
||||
properties: node.properties ?? {},
|
||||
neighborCount,
|
||||
visibleNeighborCount: neighborCount,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: neighborCount > 8,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +431,10 @@ export function GraphWorkspaceShell() {
|
||||
valid_until: null,
|
||||
properties: searchNode.properties ?? {},
|
||||
neighborCount: 0,
|
||||
visibleNeighborCount: 0,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: false,
|
||||
}
|
||||
: null;
|
||||
}, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]);
|
||||
@@ -553,6 +563,19 @@ export function GraphWorkspaceShell() {
|
||||
return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`;
|
||||
}, [viewMode, visibleSelectedNode]);
|
||||
|
||||
const requestViewMode = useCallback((nextViewMode: GraphViewMode) => {
|
||||
if (nextViewMode === "focused") {
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setViewMode("focused");
|
||||
setIsLayoutRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setViewMode("full");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
const showLoadingOverlay =
|
||||
isLoading
|
||||
|| isFetching
|
||||
@@ -629,8 +652,8 @@ export function GraphWorkspaceShell() {
|
||||
<div className="graph-toggle-cluster">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
<button onClick={() => requestViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
||||
<button onClick={() => requestViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
||||
|
||||
@@ -24,6 +24,8 @@ export const SigmaSceneAdapter = forwardRef<GraphSceneHandle, GraphSceneProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
fitView: () => canvasRef.current?.fitView(),
|
||||
focusNode: (nodeId: string) => canvasRef.current?.focusNode(nodeId),
|
||||
zoomIn: () => canvasRef.current?.zoomIn(),
|
||||
zoomOut: () => canvasRef.current?.zoomOut(),
|
||||
getRuntime: () => runtimeRef.current,
|
||||
setLayoutRunning: onLayoutRunningChange
|
||||
? (running: boolean) => {
|
||||
|
||||
@@ -5,11 +5,21 @@ export const focusCameraBehavior: GraphBehavior = {
|
||||
attach: () => {},
|
||||
detach: () => {},
|
||||
performAction: (context, action) => {
|
||||
if (action.type !== "focusNode") {
|
||||
return false;
|
||||
if (action.type === "focusNode") {
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
context.focusNodeInView(action.nodeId);
|
||||
return true;
|
||||
if (action.type === "centerSelection") {
|
||||
context.centerSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.type === "centerGroupedSelection") {
|
||||
context.centerGroupedSelectionInView(action.nodeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastFocusedNodeId = "";
|
||||
let lastSelectedNodeId = "";
|
||||
let lastViewMode = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastFocusedNodeId = "";
|
||||
lastSelectedNodeId = "";
|
||||
lastViewMode = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextViewMode = interactionState.viewMode;
|
||||
if (nextViewMode !== lastViewMode) {
|
||||
lastViewMode = nextViewMode;
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
return;
|
||||
}
|
||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
return;
|
||||
}
|
||||
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
context.dispatchAction({
|
||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||
nodeId: nextSelectedNodeId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
|
||||
onNodeSelectionChange: (nodeId: string) => void;
|
||||
onEdgeSelectionChange: (edgeId: string) => void;
|
||||
focusNodeInView: (nodeId: string) => void;
|
||||
centerSelectionInView: (nodeId: string) => void;
|
||||
centerGroupedSelectionInView: (nodeId: string) => void;
|
||||
fitCurrentView: () => void;
|
||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ export type GraphBadgeKind = "inferred" | "temporal" | "provenance";
|
||||
|
||||
type GraphNodeColorMode = "base" | "selected" | "hovered" | "path" | "muted";
|
||||
type GraphEdgeColorMode = "overview" | "backbone" | "structure" | "inspection" | "hover" | "path" | "focus" | "muted";
|
||||
const IS_DEV = Boolean((import.meta as { env?: { DEV?: boolean } }).env?.DEV);
|
||||
|
||||
export interface GraphTheme {
|
||||
palette: {
|
||||
@@ -190,6 +191,35 @@ export interface GraphTheme {
|
||||
motion: {
|
||||
cameraMs: number;
|
||||
};
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: number;
|
||||
ringSpacing: number;
|
||||
minNodeSpacing: number;
|
||||
nodePadding: number;
|
||||
overlapIterations: number;
|
||||
primaryLabelCount: number;
|
||||
};
|
||||
style: {
|
||||
nodeSizeScale: number;
|
||||
nodeBorderBoost: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
edgeSizeScale: number;
|
||||
edgeAlpha: number;
|
||||
glowAlpha: number;
|
||||
edgeVisibilityRatio: number;
|
||||
topIncidentEdges: number;
|
||||
};
|
||||
layout: {
|
||||
iterations: number;
|
||||
gravity: number;
|
||||
scalingRatio: number;
|
||||
edgeWeightInfluence: number;
|
||||
slowDown: number;
|
||||
settleMs: number;
|
||||
};
|
||||
};
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: GraphZoomTier;
|
||||
@@ -305,10 +335,10 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.88,
|
||||
labelThreshold: 0.92,
|
||||
labelBudget: 28,
|
||||
edgePriorityThreshold: 0.55,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
@@ -317,21 +347,21 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 1.02,
|
||||
labelThreshold: 0.82,
|
||||
labelBudget: 60,
|
||||
edgePriorityThreshold: 0.3,
|
||||
arrowPriorityThreshold: 0.65,
|
||||
edgeSizeScale: 1.05,
|
||||
showBadges: true,
|
||||
showCurves: true,
|
||||
showContextualArrows: true,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
maxRatio: 0.5,
|
||||
nodeScale: 1.08,
|
||||
labelThreshold: 0.6,
|
||||
labelBudget: 120,
|
||||
nodeScale: 1,
|
||||
labelThreshold: 0.8,
|
||||
labelBudget: 40,
|
||||
edgePriorityThreshold: 0,
|
||||
arrowPriorityThreshold: 0.45,
|
||||
edgeSizeScale: 1.18,
|
||||
@@ -341,7 +371,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
forceVisibleStates: ["hovered", "selected", "neighbor", "path"],
|
||||
forceVisibleStates: ["hovered", "selected", "path"],
|
||||
policies: {
|
||||
none: { minZoomTier: "inspection" },
|
||||
priority: { minZoomTier: "overview" },
|
||||
@@ -391,26 +421,26 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
nodes: {
|
||||
backgroundScale: 0.52,
|
||||
mutedAlpha: 0.08,
|
||||
mutedAlpha: 0.16,
|
||||
strokeHierarchy: {
|
||||
overview: { base: 0.05, emphasis: 0.34, muted: 0.02 },
|
||||
structure: { base: 1.05, emphasis: 1.45, muted: 0.55 },
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.92, minSize: 3.5, forceLabel: false, zIndex: 0, borderBoost: -0.18 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.28, minSize: 13.5, forceLabel: true, zIndex: 4, borderBoost: 0.28 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.14, minSize: 11.5, forceLabel: true, zIndex: 3, borderBoost: 0.24 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.96, minSize: 5.5, forceLabel: true, zIndex: 2, borderBoost: 0.04 },
|
||||
path: { color: "path", sizeMultiplier: 1.08, minSize: 7.0, forceLabel: true, zIndex: 2, borderBoost: 0.12 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.48, minSize: 1.8, forceLabel: false, zIndex: 0, borderBoost: -0.28 },
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
variants: {
|
||||
default: { sizeMultiplier: 1, borderBoost: 0, haloBoost: 0, badgeVisibleFrom: "inspection" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "structure" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "structure" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "structure" },
|
||||
temporal: { sizeMultiplier: 1.02, borderBoost: 0.12, haloBoost: 0.1, badgeKind: "temporal", badgeVisibleFrom: "inspection" },
|
||||
inferred: { sizeMultiplier: 1.05, borderBoost: 0.16, haloBoost: 0.14, badgeKind: "inferred", badgeVisibleFrom: "inspection" },
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
selectedRing: {
|
||||
@@ -473,6 +503,35 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
motion: {
|
||||
cameraMs: 380,
|
||||
},
|
||||
grouped: {
|
||||
initialLayout: {
|
||||
innerRadius: 92,
|
||||
ringSpacing: 138,
|
||||
minNodeSpacing: 112,
|
||||
nodePadding: 28,
|
||||
overlapIterations: 18,
|
||||
primaryLabelCount: 6,
|
||||
},
|
||||
style: {
|
||||
nodeSizeScale: 0.9,
|
||||
nodeBorderBoost: 0.42,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
edgeSizeScale: 0.62,
|
||||
edgeAlpha: 0.32,
|
||||
glowAlpha: 0.14,
|
||||
edgeVisibilityRatio: 0.18,
|
||||
topIncidentEdges: 2,
|
||||
},
|
||||
layout: {
|
||||
iterations: 18,
|
||||
gravity: 0.06,
|
||||
scalingRatio: 18,
|
||||
edgeWeightInfluence: 0.08,
|
||||
slowDown: 34,
|
||||
settleMs: 1500,
|
||||
},
|
||||
},
|
||||
effects: {
|
||||
pathPulse: {
|
||||
minZoomTier: "structure",
|
||||
@@ -530,7 +589,7 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
maxGroups: 8,
|
||||
},
|
||||
diagnostics: {
|
||||
enabledInDev: import.meta.env.DEV,
|
||||
enabledInDev: IS_DEV,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
}
|
||||
|
||||
const selected = context.getSelectedNodeState();
|
||||
const displayState = context.getDisplayState();
|
||||
if (!selected) {
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -82,6 +83,11 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
return left.label.localeCompare(right.label);
|
||||
})
|
||||
.slice(0, MAX_NEIGHBORS);
|
||||
const hiddenNeighborCount = displayState.selectedCollapsedNeighborIds.length;
|
||||
const aggregatedEdgeCount = context.displayGraph
|
||||
.edges()
|
||||
.map((edgeId) => context.displayGraph.getEdgeAttributes(edgeId) as { isAggregated?: boolean })
|
||||
.filter((attrs) => attrs.isAggregated).length;
|
||||
|
||||
return {
|
||||
id: NEIGHBORHOOD_PANEL_ID,
|
||||
@@ -97,6 +103,34 @@ export const neighborhoodPanelPlugin: GraphPlugin = {
|
||||
<div style={summaryStyle}>
|
||||
{selected.neighborCount.toLocaleString()} direct neighbors in the full graph
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "collapseNeighborhood" })}
|
||||
disabled={!selected.canCollapseNeighborhood || selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Collapse Neighborhood
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.dispatchAction({ type: "expandNeighborhood" })}
|
||||
disabled={!selected.isNeighborhoodCollapsed}
|
||||
style={controlButtonStyle}
|
||||
>
|
||||
Expand Neighborhood
|
||||
</button>
|
||||
</div>
|
||||
{hiddenNeighborCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{hiddenNeighborCount.toLocaleString()} lower-priority neighbors are collapsed in the current view.
|
||||
</div>
|
||||
) : null}
|
||||
{aggregatedEdgeCount > 0 ? (
|
||||
<div style={summaryStyle}>
|
||||
{aggregatedEdgeCount.toLocaleString()} aggregated structural bundle{aggregatedEdgeCount === 1 ? "" : "s"} visible.
|
||||
</div>
|
||||
) : null}
|
||||
{neighbors.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{neighbors.map((neighbor) => (
|
||||
@@ -159,6 +193,16 @@ const neighborButtonStyle: CSSProperties = {
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const controlButtonStyle: CSSProperties = {
|
||||
padding: "7px 10px",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 10,
|
||||
color: "#dce7f4",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const swatchStyle: CSSProperties = {
|
||||
width: 10,
|
||||
height: 10,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GraphTheme } from "../graphTheme";
|
||||
import type { GraphSceneRuntime } from "../scene";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphEffectToggle,
|
||||
@@ -31,6 +32,8 @@ export type GraphPluginActionRequest =
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "selectNode"; nodeId: string }
|
||||
| { type: "setViewMode"; viewMode: GraphViewMode }
|
||||
| { type: "collapseNeighborhood" }
|
||||
| { type: "expandNeighborhood" }
|
||||
| { type: "toggleEffect"; effect: GraphEffectToggle }
|
||||
| { type: "setEffect"; effect: GraphEffectToggle; enabled: boolean }
|
||||
| { type: "togglePanel"; panelId: string }
|
||||
@@ -77,6 +80,7 @@ export interface GraphPluginContext {
|
||||
getEffectsState: () => GraphEffectsState;
|
||||
getDiagnosticsSnapshot: () => GraphDiagnosticsSnapshot | null;
|
||||
getAnalyticsSnapshot: () => GraphAnalyticsSnapshot | null;
|
||||
getDisplayState: () => GraphDisplayStateSnapshot;
|
||||
isPanelOpen: (panelId: string) => boolean;
|
||||
dispatchAction: (action: GraphPluginActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
@@ -22,6 +24,8 @@ export interface GraphSceneRuntime {
|
||||
scene: unknown;
|
||||
graph: GraphSceneGraph;
|
||||
displayGraph: GraphSceneGraph;
|
||||
graphVersion: number;
|
||||
layoutMode?: GraphDisplayMeta["layoutMode"];
|
||||
requestRender: () => void;
|
||||
getCameraState: () => GraphCameraState | null;
|
||||
}
|
||||
@@ -37,7 +41,13 @@ export interface GraphSceneEventMap {
|
||||
}
|
||||
|
||||
export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
graphVersion: number;
|
||||
graphReady: boolean;
|
||||
displayGraph: GraphSceneGraph;
|
||||
displayMeta: GraphDisplayMeta;
|
||||
displayState?: GraphDisplayStateSnapshot;
|
||||
selectedNodeId: string;
|
||||
focusedNodeId: string;
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
@@ -56,6 +66,8 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
export interface GraphSceneHandle {
|
||||
fitView: () => void;
|
||||
focusNode: (nodeId: string) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
getRuntime: () => GraphSceneRuntime | null;
|
||||
setLayoutRunning?: (running: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type GraphViewMode = "focused" | "full";
|
||||
export type GraphViewMode = "focused" | "full" | "grouped";
|
||||
export type GraphLayoutSource = "provided" | "carried" | "runtime";
|
||||
export type GraphLayoutState = "idle" | "bootstrapping" | "running" | "stabilized" | "interactive" | "failed";
|
||||
export type GraphLoadPhase =
|
||||
@@ -12,6 +12,7 @@ export type GraphLoadPhase =
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
@@ -31,6 +32,28 @@ export interface GraphInteractionState {
|
||||
isLayoutRunning: boolean;
|
||||
}
|
||||
|
||||
export interface GraphDisplayStateSnapshot {
|
||||
aggregationEnabled: boolean;
|
||||
groupedViewAvailable: boolean;
|
||||
groupedViewReason: string | null;
|
||||
selectedRootNodeId: string | null;
|
||||
selectedVisibleNeighborIds: string[];
|
||||
selectedCollapsedNeighborIds: string[];
|
||||
selectedNodeKind: GraphSelectedNodeKind;
|
||||
canActivateFocused: boolean;
|
||||
resolvedFocusedNodeId: string | null;
|
||||
focusedUnavailableReason: string | null;
|
||||
}
|
||||
|
||||
export type GraphDisplayLayoutMode = "base" | "mirrored" | "owned";
|
||||
|
||||
export interface GraphDisplayMeta {
|
||||
layoutMode: GraphDisplayLayoutMode;
|
||||
positionSource: "store" | "display";
|
||||
tracksStoreNodePositions: boolean;
|
||||
hasSyntheticNodes: boolean;
|
||||
}
|
||||
|
||||
export type GraphEffectToggle =
|
||||
| "pathPulseEnabled"
|
||||
| "pathFlowEnabled"
|
||||
@@ -245,6 +268,10 @@ export interface GraphSelectedNodeState {
|
||||
valid_until?: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
neighborCount: number;
|
||||
visibleNeighborCount: number;
|
||||
collapsedNeighborCount: number;
|
||||
isNeighborhoodCollapsed: boolean;
|
||||
canCollapseNeighborhood: boolean;
|
||||
}
|
||||
|
||||
export interface GraphSelectedEdgeState {
|
||||
@@ -260,6 +287,12 @@ export interface GraphSelectedEdgeState {
|
||||
provenanceCount: number;
|
||||
familySize: number;
|
||||
siblingCount: number;
|
||||
isAggregated: boolean;
|
||||
aggregateCount: number;
|
||||
rawEdgeIds: string[];
|
||||
bundleKind: "parallel" | "bidirectional" | "community" | null;
|
||||
dominantEdgeType: string | null;
|
||||
representativeWeight: number;
|
||||
}
|
||||
|
||||
export interface GraphStageHandle {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -91,7 +91,8 @@ claude --plugin-dir ./plugins
|
||||
Or inside a session:
|
||||
|
||||
```bash
|
||||
/plugin install ./plugins
|
||||
/plugin marketplace add ./plugins
|
||||
/plugin install semantica@semantica-local
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"name": "semantica-local",
|
||||
"owner": {
|
||||
"name": "Hawksight AI",
|
||||
"url": "https://github.com/Hawksight-AI/semantica"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "semantica",
|
||||
|
||||
@@ -25,6 +25,5 @@
|
||||
"mcp"
|
||||
],
|
||||
"skills": "./skills",
|
||||
"agents": "./agents",
|
||||
"hooks": "./hooks/hooks.json"
|
||||
"agents": "./agents"
|
||||
}
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ llm-groq = ["groq>=0.4.0"]
|
||||
llm-gemini = ["google-genai>=0.1.0"]
|
||||
llm-anthropic = ["anthropic>=0.18.0"]
|
||||
llm-ollama = ["ollama>=0.1.0"]
|
||||
llm-deepseek = ["deepseek>=0.1.0"]
|
||||
llm-deepseek = ["openai>=1.0.0"]
|
||||
llm-litellm = ["litellm>=1.0.0"]
|
||||
llm-instructor = ["instructor>=1.0.0"]
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ from .ws import ConnectionManager
|
||||
|
||||
|
||||
def _install_mutation_bridge(app: FastAPI, session: GraphSession) -> None:
|
||||
previous_callback = getattr(session.graph, "mutation_callback", None)
|
||||
|
||||
def on_mutation(event_type: str, entity_id: str, payload: dict) -> None:
|
||||
session.handle_graph_mutation(event_type, entity_id, payload)
|
||||
if callable(previous_callback):
|
||||
previous_callback(event_type, entity_id, payload)
|
||||
loop = getattr(app.state, "event_loop", None)
|
||||
manager = getattr(app.state, "ws_manager", None)
|
||||
if loop is None or manager is None or loop.is_closed():
|
||||
@@ -150,6 +155,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
|
||||
if index_path.is_file():
|
||||
return FileResponse(index_path)
|
||||
return HTMLResponse(
|
||||
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
|
||||
'<title>Semantica Knowledge Explorer</title></head>'
|
||||
'<body><div id="root"></div></body></html>'
|
||||
)
|
||||
|
||||
static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
if static_dir.is_dir():
|
||||
assets_dir = static_dir / "assets"
|
||||
|
||||
@@ -136,11 +136,11 @@ def _apply_inferred_edges(
|
||||
continue
|
||||
source, target = args
|
||||
if session.get_node(source) is None:
|
||||
session.graph.add_node(source, "entity", content=source)
|
||||
session.add_node(source, "entity", content=source)
|
||||
if session.get_node(target) is None:
|
||||
session.graph.add_node(target, "entity", content=target)
|
||||
session.add_node(target, "entity", content=target)
|
||||
edge_type = body.inferred_edge_type or predicate
|
||||
session.graph.add_edge(
|
||||
session.add_edge(
|
||||
source,
|
||||
target,
|
||||
edge_type=edge_type,
|
||||
@@ -354,4 +354,6 @@ async def merge_nodes(
|
||||
return removed, edges_updated
|
||||
|
||||
removed_ids, edges_updated = await asyncio.to_thread(_do_merge)
|
||||
if removed_ids:
|
||||
await asyncio.to_thread(session.rebuild_search_index)
|
||||
return MergeResponse(merged_into=primary_id, removed_ids=removed_ids, edges_updated=edges_updated)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ...utils.helpers import classify_path_distance
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
EdgeListResponse,
|
||||
@@ -141,11 +142,14 @@ class _PathAlgorithm(str, Enum):
|
||||
dijkstra = "dijkstra"
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
path_finder = session.path_finder
|
||||
@@ -159,14 +163,18 @@ async def find_path(
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if not path_nodes:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
|
||||
|
||||
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
target=target,
|
||||
@@ -174,6 +182,9 @@ async def find_path(
|
||||
path=path_nodes,
|
||||
edge_ids=edge_ids,
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""
|
||||
"""
|
||||
Provenance routes for lineage visualization and exportable reports.
|
||||
"""
|
||||
|
||||
@@ -9,33 +9,13 @@ from typing import Any, Dict, List, Optional
|
||||
import networkx as nx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ProvenanceEdge, ProvenanceNode, ProvenanceResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/provenance", tags=["Power User Tools"])
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: str
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
_AGENT_TYPES = {"person", "organization", "system", "agent"}
|
||||
_ACTIVITY_TYPES = {"action", "event", "process", "activity", "decision", "publication"}
|
||||
|
||||
@@ -67,7 +47,7 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
||||
if edge.source_id in hop_nodes or edge.target_id in hop_nodes:
|
||||
graph.add_edge(edge.source_id, edge.target_id, label=edge.edge_type)
|
||||
|
||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=False)
|
||||
subgraph = nx.ego_graph(graph, node_id, radius=2, undirected=True)
|
||||
provenance_nodes: List[Dict[str, Any]] = []
|
||||
for graph_node_id in subgraph.nodes():
|
||||
node = session.graph.nodes.get(graph_node_id)
|
||||
@@ -85,12 +65,19 @@ def _build_provenance(session: GraphSession, node_id: Optional[str] = None) -> d
|
||||
|
||||
provenance_edges: List[Dict[str, Any]] = []
|
||||
for source, target, data in subgraph.edges(data=True):
|
||||
if target == node_id:
|
||||
direction = "upstream"
|
||||
elif source == node_id:
|
||||
direction = "downstream"
|
||||
else:
|
||||
direction = "lateral"
|
||||
provenance_edges.append(
|
||||
{
|
||||
"id": f"{source}-{target}",
|
||||
"source": source,
|
||||
"target": target,
|
||||
"label": data.get("label", "related_to"),
|
||||
"direction": direction,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -104,7 +91,7 @@ def _build_report(session: GraphSession, node_id: str) -> Dict[str, Any]:
|
||||
"node_id": node_id,
|
||||
"label": node.get("content", node_id) if node else node_id,
|
||||
"type": node.get("type", "entity") if node else "entity",
|
||||
"properties": node.get("properties", {}) if node else {},
|
||||
"properties": node.get("metadata", node.get("properties", {})) if node else {},
|
||||
"lineage": provenance,
|
||||
}
|
||||
|
||||
@@ -129,9 +116,29 @@ def _render_markdown(report: Dict[str, Any]) -> str:
|
||||
for node in report.get("lineage", {}).get("nodes", []):
|
||||
lines.append(f"- `{node['id']}` ({node['prov_type']}): {node['label']}")
|
||||
|
||||
lines.extend(["", "## Lineage Edges"])
|
||||
for edge in report.get("lineage", {}).get("edges", []):
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
edges = report.get("lineage", {}).get("edges", [])
|
||||
grouped_edges: Dict[str, List] = {"upstream": [], "downstream": [], "lateral": []}
|
||||
for edge in edges:
|
||||
direction = edge.get("direction", "lateral")
|
||||
if direction not in grouped_edges:
|
||||
direction = "lateral"
|
||||
grouped_edges[direction].append(edge)
|
||||
|
||||
if grouped_edges["upstream"]:
|
||||
lines.extend(["", "## Upstream"])
|
||||
for edge in grouped_edges["upstream"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
if grouped_edges["downstream"]:
|
||||
lines.extend(["", "## Downstream"])
|
||||
for edge in grouped_edges["downstream"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
if grouped_edges["lateral"]:
|
||||
lines.extend(["", "## Lateral"])
|
||||
for edge in grouped_edges["lateral"]:
|
||||
lines.append(f"- `{edge['source']}` -[{edge['label']}]-> `{edge['target']}`")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ class PathResponse(BaseModel):
|
||||
path: List[str]
|
||||
edge_ids: List[str] = Field(default_factory=list)
|
||||
total_weight: float = 0.0
|
||||
directed: bool = True
|
||||
hop_count: int = 0
|
||||
distance_band: str = "direct"
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
@@ -285,3 +288,23 @@ class MergeResponse(BaseModel):
|
||||
merged_into: str
|
||||
removed_ids: List[str]
|
||||
edges_updated: int
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
direction: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
@@ -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
@@ -4,12 +4,15 @@ Semantica Explorer session helpers.
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, UTC
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from ..context.context_graph import ContextGraph, _resolve_edge_identity
|
||||
from .search_index import GraphSearchIndex
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
@@ -28,6 +31,8 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphSession:
|
||||
"""Thread-safe session wrapper around a loaded ``ContextGraph``."""
|
||||
@@ -35,6 +40,7 @@ class GraphSession:
|
||||
def __init__(self, graph: ContextGraph) -> None:
|
||||
self.graph = graph
|
||||
self._lock = threading.RLock()
|
||||
self._search_index = GraphSearchIndex()
|
||||
|
||||
self.annotations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -46,6 +52,7 @@ class GraphSession:
|
||||
self._similarity: Any = None
|
||||
self._link_predictor: Any = None
|
||||
self._validator: Any = None
|
||||
self.rebuild_search_index()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "GraphSession":
|
||||
@@ -390,6 +397,28 @@ class GraphSession:
|
||||
with self._lock:
|
||||
return self.graph.get_neighbors(node_id, hops=depth)
|
||||
|
||||
def rebuild_search_index(self) -> None:
|
||||
with self._lock:
|
||||
normalized_nodes = [
|
||||
self.normalize_node(node.to_dict())
|
||||
for node in self.graph.nodes.values()
|
||||
if node is not None
|
||||
]
|
||||
self._search_index.rebuild(normalized_nodes)
|
||||
|
||||
def handle_graph_mutation(self, event_type: str, entity_id: str, payload: Dict[str, Any]) -> None:
|
||||
normalized_event = str(event_type or "").upper()
|
||||
if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
|
||||
normalized_node = self.normalize_node(payload or {})
|
||||
if normalized_node.get("id"):
|
||||
with self._lock:
|
||||
self._search_index.upsert(normalized_node)
|
||||
elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
|
||||
with self._lock:
|
||||
self._search_index.remove(str(entity_id))
|
||||
elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
|
||||
self.rebuild_search_index()
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
@@ -397,64 +426,34 @@ class GraphSession:
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
filters = filters or {}
|
||||
try:
|
||||
with self._lock:
|
||||
raw = self.graph.query(query)[:limit]
|
||||
except Exception:
|
||||
raw = []
|
||||
started_at = time.perf_counter()
|
||||
matches, diagnostics = self._search_index.search(query, limit=limit, filters=filters)
|
||||
|
||||
if not raw:
|
||||
nodes, _ = self.get_nodes(search=query, skip=0, limit=max(limit * 5, limit))
|
||||
scored = []
|
||||
lowered_query = query.lower().strip()
|
||||
for node in nodes:
|
||||
haystacks = [
|
||||
str(node.get("id", "")),
|
||||
str(node.get("content", "")),
|
||||
json.dumps(node.get("properties", {}), default=str),
|
||||
]
|
||||
best_score = 0.0
|
||||
for haystack in haystacks:
|
||||
lowered = haystack.lower()
|
||||
if lowered == lowered_query:
|
||||
best_score = max(best_score, 1.0)
|
||||
elif lowered_query in lowered:
|
||||
best_score = max(best_score, min(0.9, len(lowered_query) / max(len(lowered), 1)))
|
||||
if best_score > 0:
|
||||
scored.append({"node": node, "score": round(best_score, 4)})
|
||||
raw = sorted(scored, key=lambda item: item["score"], reverse=True)[:limit]
|
||||
|
||||
normalized = []
|
||||
for result in raw:
|
||||
result_node = result.get("node", {})
|
||||
node = (
|
||||
self.normalize_node(result_node)
|
||||
if "properties" in result_node or "metadata" in result_node or "content" in result_node
|
||||
else result_node
|
||||
)
|
||||
|
||||
filter_type = filters.get("type") or filters.get("node_type")
|
||||
if filter_type and node["type"] != filter_type:
|
||||
continue
|
||||
|
||||
min_confidence = self._coerce_float(filters.get("min_confidence"))
|
||||
node_confidence = self._coerce_float(node["properties"].get("confidence"))
|
||||
if min_confidence is not None and (
|
||||
node_confidence is None or node_confidence < min_confidence
|
||||
):
|
||||
continue
|
||||
|
||||
tags_filter = filters.get("tags")
|
||||
if tags_filter:
|
||||
node_tags = node["properties"].get("tags") or []
|
||||
if isinstance(node_tags, str):
|
||||
node_tags = [node_tags]
|
||||
if not set(tags_filter).issubset(set(node_tags)):
|
||||
normalized_results: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for node_id, score in matches:
|
||||
raw_node = self.graph.find_node(node_id)
|
||||
if raw_node is None:
|
||||
continue
|
||||
node_payload = raw_node.to_dict() if hasattr(raw_node, "to_dict") else raw_node
|
||||
normalized_results.append(
|
||||
{
|
||||
"node": self.normalize_node(node_payload),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
|
||||
normalized.append({"node": node, "score": result.get("score", 0.0)})
|
||||
|
||||
return normalized[:limit]
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
logger.debug(
|
||||
"Explorer search query=%r limit=%s cache_hit=%s path=%s candidates=%s duration_ms=%s",
|
||||
query,
|
||||
limit,
|
||||
diagnostics.get("cache_hit"),
|
||||
diagnostics.get("path"),
|
||||
diagnostics.get("candidates"),
|
||||
duration_ms,
|
||||
)
|
||||
return normalized_results[:limit]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
@@ -594,8 +593,51 @@ class GraphSession:
|
||||
|
||||
def add_nodes(self, nodes: List[Dict[str, Any]]) -> int:
|
||||
with self._lock:
|
||||
return self.graph.add_nodes(nodes)
|
||||
added = self.graph.add_nodes(nodes)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
self.rebuild_search_index()
|
||||
return added
|
||||
|
||||
def add_edges(self, edges: List[Dict[str, Any]]) -> int:
|
||||
with self._lock:
|
||||
return self.graph.add_edges(edges)
|
||||
added = self.graph.add_edges(edges)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
self.rebuild_search_index()
|
||||
return added
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
node_id: str,
|
||||
node_type: str,
|
||||
content: Optional[str] = None,
|
||||
**properties: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
added = self.graph.add_node(node_id, node_type, content=content, **properties)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
if added and not has_mutation_callback:
|
||||
normalized = self.get_node(node_id)
|
||||
if normalized is not None:
|
||||
self._search_index.upsert(normalized)
|
||||
return added
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
edge_type: str = "related_to",
|
||||
weight: float = 1.0,
|
||||
**properties: Any,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
added = self.graph.add_edge(
|
||||
source_id,
|
||||
target_id,
|
||||
edge_type=edge_type,
|
||||
weight=weight,
|
||||
**properties,
|
||||
)
|
||||
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
|
||||
return added
|
||||
|
||||
@@ -328,6 +328,18 @@ class OWLExporter:
|
||||
lines.append("</rdf:RDF>")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _escape_ttl_str(value: str) -> str:
|
||||
"""Escape a string value for safe embedding in a Turtle string literal."""
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
|
||||
def _ttl_block(self, subject_uri: str, rdf_type: str, predicates: List[str]) -> str:
|
||||
"""Build a valid Turtle subject block from accumulated predicate strings."""
|
||||
stmt = f"<{subject_uri}> a {rdf_type}"
|
||||
for pred in predicates:
|
||||
stmt += f" ;\n {pred}"
|
||||
return stmt + " ."
|
||||
|
||||
def _export_owl_turtle(self, ontology: Dict[str, Any], **options) -> str:
|
||||
"""
|
||||
Export ontology to OWL Turtle format.
|
||||
@@ -342,6 +354,7 @@ class OWLExporter:
|
||||
Returns:
|
||||
String containing OWL Turtle serialization
|
||||
"""
|
||||
esc = self._escape_ttl_str
|
||||
ontology_uri = ontology.get("uri") or self.ontology_uri
|
||||
ontology_name = ontology.get("name", "SemanticaOntology")
|
||||
version = ontology.get("version") or self.version
|
||||
@@ -357,63 +370,73 @@ class OWLExporter:
|
||||
lines.append("")
|
||||
|
||||
# Ontology declaration
|
||||
lines.append(f"<{ontology_uri}> a owl:Ontology ;")
|
||||
lines.append(f' rdfs:label "{ontology_name}" ;')
|
||||
lines.append(f' owl:versionInfo "{version}" .')
|
||||
if ontology.get("description"):
|
||||
lines.append(f' rdfs:comment "{ontology.get("description")}" ;')
|
||||
onto_predicates = [
|
||||
f'rdfs:label "{esc(ontology_name)}"',
|
||||
f'owl:versionInfo "{esc(version)}"',
|
||||
]
|
||||
description = ontology.get("description")
|
||||
if description:
|
||||
onto_predicates.append(f'rdfs:comment "{esc(description)}"')
|
||||
lines.append(self._ttl_block(ontology_uri, "owl:Ontology", onto_predicates))
|
||||
lines.append("")
|
||||
|
||||
# Classes
|
||||
classes = ontology.get("classes", [])
|
||||
for cls in classes:
|
||||
for cls in ontology.get("classes", []):
|
||||
class_uri = cls.get("uri") or cls.get("id", "")
|
||||
class_name = cls.get("name") or cls.get("label", "")
|
||||
|
||||
lines.append(f"<{class_uri}> a owl:Class ;")
|
||||
lines.append(f' rdfs:label "{class_name}" .')
|
||||
|
||||
if cls.get("comment"):
|
||||
lines.append(f' rdfs:comment "{cls.get("comment")}" ;')
|
||||
|
||||
if cls.get("subClassOf"):
|
||||
parent = cls.get("subClassOf")
|
||||
lines.append(f" rdfs:subClassOf <{parent}> ;")
|
||||
|
||||
# Remove trailing semicolon and add period
|
||||
if lines[-1].endswith(" ;"):
|
||||
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||
else:
|
||||
lines.append(" .")
|
||||
predicates = [f'rdfs:label "{esc(class_name)}"']
|
||||
comment = cls.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
sub_class = cls.get("subClassOf")
|
||||
if sub_class:
|
||||
predicates.append(f"rdfs:subClassOf <{sub_class}>")
|
||||
equiv = cls.get("equivalentClass")
|
||||
if equiv:
|
||||
predicates.append(f"owl:equivalentClass <{equiv}>")
|
||||
lines.append(self._ttl_block(class_uri, "owl:Class", predicates))
|
||||
lines.append("")
|
||||
|
||||
# Object properties
|
||||
object_properties = ontology.get("object_properties", [])
|
||||
for prop in object_properties:
|
||||
for prop in ontology.get("object_properties", []):
|
||||
prop_uri = prop.get("uri") or prop.get("id", "")
|
||||
prop_name = prop.get("name") or prop.get("label", "")
|
||||
|
||||
lines.append(f"<{prop_uri}> a owl:ObjectProperty ;")
|
||||
lines.append(f' rdfs:label "{prop_name}" .')
|
||||
|
||||
if prop.get("domain"):
|
||||
domain = prop.get("domain")
|
||||
predicates = [f'rdfs:label "{esc(prop_name)}"']
|
||||
comment = prop.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
domain = prop.get("domain")
|
||||
if domain:
|
||||
if isinstance(domain, list):
|
||||
for d in domain:
|
||||
lines.append(f" rdfs:domain <{d}> ;")
|
||||
predicates.append(f"rdfs:domain <{d}>")
|
||||
else:
|
||||
lines.append(f" rdfs:domain <{domain}> ;")
|
||||
|
||||
if prop.get("range"):
|
||||
range_val = prop.get("range")
|
||||
predicates.append(f"rdfs:domain <{domain}>")
|
||||
range_val = prop.get("range")
|
||||
if range_val:
|
||||
if isinstance(range_val, list):
|
||||
for r in range_val:
|
||||
lines.append(f" rdfs:range <{r}> ;")
|
||||
predicates.append(f"rdfs:range <{r}>")
|
||||
else:
|
||||
lines.append(f" rdfs:range <{range_val}> ;")
|
||||
predicates.append(f"rdfs:range <{range_val}>")
|
||||
lines.append(self._ttl_block(prop_uri, "owl:ObjectProperty", predicates))
|
||||
lines.append("")
|
||||
|
||||
if lines[-1].endswith(" ;"):
|
||||
lines[-1] = lines[-1].rstrip(" ;") + " ."
|
||||
# Data properties
|
||||
for prop in ontology.get("data_properties", []):
|
||||
prop_uri = prop.get("uri") or prop.get("id", "")
|
||||
prop_name = prop.get("name") or prop.get("label", "")
|
||||
predicates = [f'rdfs:label "{esc(prop_name)}"']
|
||||
comment = prop.get("comment")
|
||||
if comment:
|
||||
predicates.append(f'rdfs:comment "{esc(comment)}"')
|
||||
domain = prop.get("domain")
|
||||
if domain:
|
||||
predicates.append(f"rdfs:domain <{domain}>")
|
||||
range_type = prop.get("range")
|
||||
if range_type:
|
||||
predicates.append(f"rdfs:range xsd:{range_type}")
|
||||
lines.append(self._ttl_block(prop_uri, "owl:DatatypeProperty", predicates))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
+38
-19
@@ -104,7 +104,8 @@ class PathFinder:
|
||||
source: str,
|
||||
target: str,
|
||||
weight_attribute: str = "weight",
|
||||
default_weight: float = 1.0
|
||||
default_weight: float = 1.0,
|
||||
directed: bool = True
|
||||
) -> List[str]:
|
||||
"""
|
||||
Find shortest path using Dijkstra's algorithm.
|
||||
@@ -125,32 +126,34 @@ class PathFinder:
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Finding Dijkstra shortest path from {source} to {target}")
|
||||
|
||||
|
||||
# Validate nodes exist
|
||||
if not self._node_exists(graph, source):
|
||||
raise ValueError(f"Source node {source} not found")
|
||||
if not self._node_exists(graph, target):
|
||||
raise ValueError(f"Target node {target} not found")
|
||||
|
||||
|
||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
||||
|
||||
# Dijkstra's algorithm
|
||||
distances = {source: 0.0}
|
||||
previous = {}
|
||||
priority_queue = [(0.0, source)]
|
||||
visited = set()
|
||||
|
||||
|
||||
while priority_queue:
|
||||
current_distance, current_node = heapq.heappop(priority_queue)
|
||||
|
||||
|
||||
if current_node in visited:
|
||||
continue
|
||||
|
||||
|
||||
visited.add(current_node)
|
||||
|
||||
|
||||
if current_node == target:
|
||||
break
|
||||
|
||||
|
||||
# Explore neighbors
|
||||
for neighbor, edge_data in self._get_neighbors(graph, current_node):
|
||||
for neighbor, edge_data in self._get_neighbors(traversal_graph, current_node):
|
||||
if neighbor in visited:
|
||||
continue
|
||||
|
||||
@@ -350,44 +353,48 @@ class PathFinder:
|
||||
self,
|
||||
graph: Any,
|
||||
source: str,
|
||||
target: str
|
||||
target: str,
|
||||
directed: bool = True
|
||||
) -> List[str]:
|
||||
"""
|
||||
Find shortest path using BFS (unweighted).
|
||||
|
||||
|
||||
Args:
|
||||
graph: Graph object (NetworkX or similar)
|
||||
source: Source node ID
|
||||
target: Target node ID
|
||||
|
||||
directed: If False, treat the graph as undirected for traversal
|
||||
|
||||
Returns:
|
||||
List of node IDs representing the shortest path
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: If source or target not found
|
||||
"""
|
||||
try:
|
||||
self.logger.info(f"Finding BFS shortest path from {source} to {target}")
|
||||
|
||||
|
||||
# Validate nodes exist
|
||||
if not self._node_exists(graph, source):
|
||||
raise ValueError(f"Source node {source} not found")
|
||||
if not self._node_exists(graph, target):
|
||||
raise ValueError(f"Target node {target} not found")
|
||||
|
||||
|
||||
traversal_graph = graph if directed else self._make_undirected_view(graph)
|
||||
|
||||
# BFS algorithm
|
||||
queue = deque([(source, [source])])
|
||||
visited = {source}
|
||||
|
||||
|
||||
while queue:
|
||||
current, path = queue.popleft()
|
||||
|
||||
|
||||
if current == target:
|
||||
self.logger.info(f"Found BFS path of length {len(path)}")
|
||||
return path
|
||||
|
||||
|
||||
# Explore neighbors
|
||||
for neighbor, _ in self._get_neighbors(graph, current):
|
||||
for neighbor, _ in self._get_neighbors(traversal_graph, current):
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor]))
|
||||
@@ -564,6 +571,18 @@ class PathFinder:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _make_undirected_view(self, graph: Any) -> Any:
|
||||
"""Return an undirected view of the graph for bidirectional traversal.
|
||||
|
||||
For NetworkX directed graphs this calls ``to_undirected()``, which
|
||||
preserves all edge attributes. For graph types that have no such
|
||||
method the original object is returned as a fallback — callers that
|
||||
already expose undirected neighbors will still work correctly.
|
||||
"""
|
||||
if hasattr(graph, "to_undirected"):
|
||||
return graph.to_undirected()
|
||||
return graph
|
||||
|
||||
def _get_neighbors(self, graph: Any, node: str) -> List[Tuple[str, Any]]:
|
||||
"""Get neighbors of a node with edge data."""
|
||||
neighbors = []
|
||||
|
||||
@@ -397,6 +397,7 @@ class BaseProvider:
|
||||
create_kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
response = client.chat.completions.create(**create_kwargs)
|
||||
verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)
|
||||
if verbose_mode:
|
||||
import sys
|
||||
print(f" [BaseProvider.generate_typed] Typed response received via instructor ({provider_name}).", flush=True, file=sys.stdout)
|
||||
@@ -939,20 +940,22 @@ class DeepSeekProvider(BaseProvider):
|
||||
def __init__(self, api_key: Optional[str] = None, model: str = "deepseek-chat", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.api_key = api_key or config.get_api_key("deepseek")
|
||||
self.base_url = "https://api.deepseek.com/v1"
|
||||
self.model = model
|
||||
self.base_url = "https://api.deepseek.com/v1"
|
||||
self.client = None
|
||||
self._init_client()
|
||||
|
||||
def _init_client(self):
|
||||
try:
|
||||
import deepseek # type: ignore[import-untyped]
|
||||
from openai import OpenAI
|
||||
|
||||
if self.api_key:
|
||||
self.client = deepseek.Client(api_key=self.api_key)
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
except (ImportError, OSError):
|
||||
self.client = None
|
||||
self.logger.warning(
|
||||
"deepseek library not installed. Install with: pip install semantica[llm-deepseek]"
|
||||
"openai library not installed. Install with: pip install semantica[llm-openai]"
|
||||
)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
|
||||
+15
-3
@@ -188,10 +188,22 @@ async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="API route not found")
|
||||
|
||||
# Root path — serve index.html if built, otherwise a welcome JSON response
|
||||
if full_path in ("", "/"):
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.is_file():
|
||||
return FileResponse(index_file)
|
||||
return JSONResponse({
|
||||
"name": "Semantica Knowledge Explorer",
|
||||
"version": __version__,
|
||||
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
})
|
||||
|
||||
normalized_path = os.path.normpath(full_path)
|
||||
if (
|
||||
normalized_path in ("", ".")
|
||||
or os.path.isabs(normalized_path)
|
||||
os.path.isabs(normalized_path)
|
||||
or normalized_path == ".."
|
||||
or normalized_path.startswith(".." + os.sep)
|
||||
):
|
||||
@@ -200,7 +212,7 @@ async def serve_spa(full_path: str):
|
||||
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
||||
safe_rel_path = normalized_path.lstrip("/\\")
|
||||
rel_parts = Path(safe_rel_path).parts
|
||||
if any(part in ("", ".", "..") for part in rel_parts):
|
||||
if any(part in (".", "..") for part in rel_parts):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
static_dir_resolved = STATIC_DIR.resolve()
|
||||
|
||||
@@ -562,3 +562,25 @@ def retry_on_error(
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def classify_path_distance(hop_count: int) -> str:
|
||||
"""Classify a path hop count into a human-readable distance band.
|
||||
|
||||
Bands:
|
||||
"direct" — 0–1 hops (single edge or self)
|
||||
"near" — 2–3 hops (closely related)
|
||||
"mid-range" — 4–6 hops (reachable but separated)
|
||||
"distant" — 7+ hops (weakly coupled)
|
||||
|
||||
This is the single source of truth for distance-band thresholds used by
|
||||
both the Explorer API (PathResponse.distance_band) and the KGVisualizer
|
||||
(highlight_path edge styling).
|
||||
"""
|
||||
if hop_count <= 1:
|
||||
return "direct"
|
||||
if hop_count <= 3:
|
||||
return "near"
|
||||
if hop_count <= 6:
|
||||
return "mid-range"
|
||||
return "distant"
|
||||
|
||||
@@ -61,6 +61,7 @@ try:
|
||||
except Exception: # pragma: no cover
|
||||
_KnowledgeGraph = None # type: ignore[assignment,misc]
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .utils.color_schemes import ColorPalette, ColorScheme
|
||||
from .utils.export_formats import (
|
||||
@@ -191,6 +192,7 @@ class KGVisualizer:
|
||||
node_color_by: str = "type",
|
||||
node_size_by: Optional[str] = None,
|
||||
hover_data: Optional[List[str]] = None,
|
||||
highlight_path: Optional[List[str]] = None,
|
||||
**options,
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
@@ -212,6 +214,9 @@ class KGVisualizer:
|
||||
node_color_by: Property to map to node color (default: "type")
|
||||
node_size_by: Property to map to node size (default: fixed)
|
||||
hover_data: List of properties to show in hover tooltip
|
||||
highlight_path: Optional ordered list of node IDs forming a path to
|
||||
highlight with distance-aware edge styling (opacity and stroke
|
||||
weight reflect hop count along the path).
|
||||
**options: Additional visualization options
|
||||
|
||||
Returns:
|
||||
@@ -261,13 +266,14 @@ class KGVisualizer:
|
||||
tracking_id, message="Generating visualization..."
|
||||
)
|
||||
result = self._visualize_network_plotly(
|
||||
nodes,
|
||||
edges,
|
||||
output,
|
||||
file_path,
|
||||
nodes,
|
||||
edges,
|
||||
output,
|
||||
file_path,
|
||||
node_color_by=node_color_by,
|
||||
node_size_by=node_size_by,
|
||||
hover_data=hover_data,
|
||||
highlight_path=highlight_path,
|
||||
**options
|
||||
)
|
||||
|
||||
@@ -564,6 +570,21 @@ class KGVisualizer:
|
||||
|
||||
return edges
|
||||
|
||||
@staticmethod
|
||||
def _path_edge_style(distance_band: str) -> Tuple[float, float]:
|
||||
"""Return (opacity, width) for a path edge based on its distance band.
|
||||
|
||||
Bands come from ``classify_path_distance`` in ``utils.helpers`` — the
|
||||
single source of truth for hop-count thresholds.
|
||||
"""
|
||||
if distance_band == "direct":
|
||||
return (1.0, 4.0)
|
||||
if distance_band == "near":
|
||||
return (0.85, 3.0)
|
||||
if distance_band == "mid-range":
|
||||
return (0.6, 2.0)
|
||||
return (0.35, 1.5) # "distant"
|
||||
|
||||
def _visualize_network_plotly(
|
||||
self,
|
||||
nodes: List[Dict[str, Any]],
|
||||
@@ -573,6 +594,7 @@ class KGVisualizer:
|
||||
node_color_by: str = "type",
|
||||
node_size_by: Optional[str] = None,
|
||||
hover_data: Optional[List[str]] = None,
|
||||
highlight_path: Optional[List[str]] = None,
|
||||
**options,
|
||||
) -> Optional[Any]:
|
||||
"""Create Plotly network visualization."""
|
||||
@@ -675,47 +697,73 @@ class KGVisualizer:
|
||||
|
||||
node_text.append(text)
|
||||
|
||||
# Prepare edge traces
|
||||
edge_x = []
|
||||
edge_y = []
|
||||
|
||||
# Build path edge lookup for highlight_path support
|
||||
path_edge_set: set = set()
|
||||
path_distance_band = "direct"
|
||||
if highlight_path and len(highlight_path) >= 2:
|
||||
path_hop_count = len(highlight_path) - 1
|
||||
path_distance_band = classify_path_distance(path_hop_count)
|
||||
# Only add the directed edges that actually form the path (A→B, not B→A).
|
||||
# Adding the reverse would incorrectly highlight unrelated back-edges.
|
||||
for i in range(path_hop_count):
|
||||
path_edge_set.add((highlight_path[i], highlight_path[i + 1]))
|
||||
# Warn if any path node has no layout position (silent highlight failure).
|
||||
missing = [n for n in highlight_path if n not in pos]
|
||||
if missing:
|
||||
self.logger.warning(
|
||||
"highlight_path contains node IDs not found in the graph: %s",
|
||||
missing,
|
||||
)
|
||||
|
||||
path_opacity, path_width = self._path_edge_style(path_distance_band)
|
||||
|
||||
# Prepare edge traces — split into background (non-path) and path edges
|
||||
edge_x: List = []
|
||||
edge_y: List = []
|
||||
path_edge_x: List = []
|
||||
path_edge_y: List = []
|
||||
|
||||
# Prepare edge label traces and annotations (for arrows)
|
||||
edge_label_x = []
|
||||
edge_label_y = []
|
||||
edge_label_text = []
|
||||
annotations = []
|
||||
|
||||
|
||||
# Limit detailed edge rendering for performance if graph is too large
|
||||
show_detailed_edges = len(edges) < 500
|
||||
|
||||
|
||||
for edge in edges:
|
||||
source_pos = pos.get(edge["source"])
|
||||
target_pos = pos.get(edge["target"])
|
||||
if source_pos and target_pos:
|
||||
x0, y0 = source_pos
|
||||
x1, y1 = target_pos
|
||||
edge_x.extend([x0, x1, None])
|
||||
edge_y.extend([y0, y1, None])
|
||||
|
||||
|
||||
is_path_edge = (edge["source"], edge["target"]) in path_edge_set
|
||||
if is_path_edge:
|
||||
path_edge_x.extend([x0, x1, None])
|
||||
path_edge_y.extend([y0, y1, None])
|
||||
else:
|
||||
edge_x.extend([x0, x1, None])
|
||||
edge_y.extend([y0, y1, None])
|
||||
|
||||
if show_detailed_edges:
|
||||
# Calculate midpoint for label
|
||||
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
|
||||
|
||||
if edge.get("label"):
|
||||
edge_label_x.append(mx)
|
||||
edge_label_y.append(my)
|
||||
edge_label_text.append(edge["label"])
|
||||
|
||||
|
||||
# Add arrow annotation
|
||||
# Adjust arrow to point slightly before the node to avoid overlap with node marker
|
||||
# This is approximate; precise calculation requires node size
|
||||
annotations.append(
|
||||
dict(
|
||||
ax=x0, ay=y0, axref='x', ayref='y',
|
||||
x=x1, y=y1, xref='x', yref='y',
|
||||
arrowhead=2, arrowsize=1, arrowwidth=1,
|
||||
arrowcolor="#888", opacity=0.6,
|
||||
standoff=15 # Distance from target node
|
||||
standoff=15
|
||||
)
|
||||
)
|
||||
|
||||
@@ -728,9 +776,22 @@ class KGVisualizer:
|
||||
showlegend=False,
|
||||
opacity=0.5
|
||||
)
|
||||
|
||||
|
||||
traces = [edge_trace]
|
||||
|
||||
|
||||
# Overlay highlighted path edges with distance-aware styling
|
||||
if path_edge_x:
|
||||
path_trace = go.Scatter(
|
||||
x=path_edge_x,
|
||||
y=path_edge_y,
|
||||
line=dict(width=path_width, color="#e05c00"),
|
||||
hoverinfo="none",
|
||||
mode="lines",
|
||||
showlegend=False,
|
||||
opacity=path_opacity,
|
||||
)
|
||||
traces.append(path_trace)
|
||||
|
||||
if show_detailed_edges and edge_label_text:
|
||||
edge_label_trace = go.Scatter(
|
||||
x=edge_label_x,
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
@@ -35,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
|
||||
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
|
||||
graph.add_node("web_dev", node_type="concept", content="Web Development", x=24, y=30)
|
||||
graph.add_node("ml", node_type="concept", content="Machine Learning", x=45, y=60)
|
||||
graph.add_node(
|
||||
"metformin",
|
||||
node_type="drug",
|
||||
content="Metformin",
|
||||
aliases=["Glucophage"],
|
||||
confidence="0.97",
|
||||
tags=["drug", "featured"],
|
||||
x=22,
|
||||
y=33,
|
||||
)
|
||||
graph.add_node(
|
||||
"decision_1",
|
||||
node_type="decision",
|
||||
@@ -243,6 +254,74 @@ class TestSearchAndStats:
|
||||
assert payload["total"] >= 1
|
||||
assert all(item["node"]["type"] == "language" for item in payload["results"])
|
||||
|
||||
def test_search_exact_and_prefix(self, client):
|
||||
exact_response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "Metformin", "limit": 5},
|
||||
)
|
||||
assert exact_response.status_code == 200
|
||||
exact_payload = exact_response.json()
|
||||
assert exact_payload["results"][0]["node"]["id"] == "metformin"
|
||||
|
||||
prefix_response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "metf", "limit": 5},
|
||||
)
|
||||
assert prefix_response.status_code == 200
|
||||
prefix_payload = prefix_response.json()
|
||||
assert any(item["node"]["id"] == "metformin" for item in prefix_payload["results"])
|
||||
|
||||
def test_search_filters_and_cache_stability(self, client):
|
||||
body = {
|
||||
"query": "framework",
|
||||
"filters": {"type": "decision", "min_confidence": 0.8},
|
||||
"limit": 5,
|
||||
}
|
||||
first_response = client.post("/api/graph/search", json=body)
|
||||
second_response = client.post("/api/graph/search", json=body)
|
||||
|
||||
assert first_response.status_code == 200
|
||||
assert second_response.status_code == 200
|
||||
assert first_response.json() == second_response.json()
|
||||
results = first_response.json()["results"]
|
||||
assert [item["node"]["id"] for item in results] == ["decision_1"]
|
||||
|
||||
def test_search_sees_new_nodes_after_mutation(self, client):
|
||||
session = client.app.state.session
|
||||
assert session.add_node(
|
||||
"metformin_hcl",
|
||||
"drug",
|
||||
content="Metformin Hydrochloride",
|
||||
aliases=["Glucophage XR"],
|
||||
confidence="0.93",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "glucophage", "limit": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result_ids = [item["node"]["id"] for item in response.json()["results"]]
|
||||
assert "metformin" in result_ids
|
||||
assert "metformin_hcl" in result_ids
|
||||
|
||||
def test_search_secondary_scan_fallback_matches_non_curated_properties(self, client):
|
||||
session = client.app.state.session
|
||||
assert session.add_node(
|
||||
"fallback_node",
|
||||
"entity",
|
||||
content="Alpha",
|
||||
description="rareterm",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/graph/search",
|
||||
json={"query": "rareterm", "limit": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result_ids = [item["node"]["id"] for item in response.json()["results"]]
|
||||
assert "fallback_node" in result_ids
|
||||
|
||||
def test_stats(self, client):
|
||||
response = client.get("/api/graph/stats")
|
||||
assert response.status_code == 200
|
||||
@@ -638,3 +717,177 @@ class TestGenericGraphFileLoading:
|
||||
assert repeat.status_code == 200
|
||||
repeat_ids = [edge["id"] for edge in repeat.json()["edges"]]
|
||||
assert repeat_ids == ["edge-alpha", "edge-beta"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bidirectional path-finding tests (issue #469)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_path_session() -> GraphSession:
|
||||
"""Return a GraphSession whose build_graph_dict yields an nx.DiGraph with A→B only.
|
||||
|
||||
GraphSession wraps a ContextGraph (required by create_app), but we patch
|
||||
build_graph_dict so PathFinder receives an actual NetworkX DiGraph — the
|
||||
graph type the Explorer is designed to traverse for path queries.
|
||||
"""
|
||||
cg = ContextGraph(advanced_analytics=False)
|
||||
cg.add_node("A", node_type="entity", content="Node A")
|
||||
cg.add_node("B", node_type="entity", content="Node B")
|
||||
cg.add_edge("A", "B", edge_type="connects")
|
||||
|
||||
session = GraphSession(cg)
|
||||
|
||||
# Patch build_graph_dict to return the directed NetworkX graph that
|
||||
# PathFinder needs. The ContextGraph dict format is not traversable by
|
||||
# PathFinder; this mimics how a KG-backed session would expose the graph.
|
||||
digraph = nx.DiGraph()
|
||||
digraph.add_edge("A", "B")
|
||||
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
|
||||
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def path_client():
|
||||
session = _make_path_session()
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestBidirectionalPathRoute:
|
||||
"""API-level tests for directed=true/false on GET /api/graph/node/{id}/path."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# directed=true (default) — existing directed-only behaviour
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_directed_true_forward_path_found(self, path_client):
|
||||
"""A→B exists: forward query with directed=true must succeed."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=true")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["A", "B"]
|
||||
assert body["directed"] is True
|
||||
|
||||
def test_directed_true_reverse_returns_404(self, path_client):
|
||||
"""Only A→B exists: reverse query with directed=true must return 404."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A&directed=true")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_param_reverse_returns_404(self, path_client):
|
||||
"""Omitting directed= must preserve current directed behaviour (404 for reverse)."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# directed=false — new undirected traversal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_directed_false_reverse_path_found(self, path_client):
|
||||
"""directed=false must find B→A even though only A→B exists."""
|
||||
resp = path_client.get("/api/graph/node/B/path?target=A&directed=false")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_directed_false_forward_path_found(self, path_client):
|
||||
"""directed=false must not break the natural A→B direction."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["A", "B"]
|
||||
assert body["directed"] is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Algorithm variants
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_dijkstra_directed_false_reverse(self, path_client):
|
||||
resp = path_client.get(
|
||||
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=false"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["algorithm"] == "dijkstra"
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_dijkstra_directed_true_reverse_returns_404(self, path_client):
|
||||
resp = path_client.get(
|
||||
"/api/graph/node/B/path?target=A&algorithm=dijkstra&directed=true"
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PathResponse schema
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_response_schema_includes_directed_field(self, path_client):
|
||||
"""PathResponse must always include the directed field."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "directed" in body
|
||||
|
||||
def test_response_directed_reflects_query_param(self, path_client):
|
||||
resp_true = path_client.get("/api/graph/node/A/path?target=B&directed=true")
|
||||
resp_false = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
assert resp_true.json()["directed"] is True
|
||||
assert resp_false.json()["directed"] is False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# hop_count and distance_band — issue #472
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_response_includes_hop_count_and_distance_band(self, path_client):
|
||||
"""PathResponse must include hop_count and distance_band fields."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "hop_count" in body
|
||||
assert "distance_band" in body
|
||||
|
||||
def test_one_hop_path_is_direct(self, path_client):
|
||||
"""A single-edge path (1 hop) must return distance_band='direct'."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["hop_count"] == 1
|
||||
assert body["distance_band"] == "direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify_distance unit tests — issue #472
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from semantica.utils.helpers import classify_path_distance
|
||||
|
||||
|
||||
class TestClassifyDistance:
|
||||
"""Unit tests covering all four band boundaries."""
|
||||
|
||||
def test_zero_hops_is_direct(self):
|
||||
assert classify_path_distance(0) == "direct"
|
||||
|
||||
def test_one_hop_is_direct(self):
|
||||
assert classify_path_distance(1) == "direct"
|
||||
|
||||
def test_two_hops_is_near(self):
|
||||
assert classify_path_distance(2) == "near"
|
||||
|
||||
def test_three_hops_is_near(self):
|
||||
assert classify_path_distance(3) == "near"
|
||||
|
||||
def test_four_hops_is_mid_range(self):
|
||||
assert classify_path_distance(4) == "mid-range"
|
||||
|
||||
def test_six_hops_is_mid_range(self):
|
||||
assert classify_path_distance(6) == "mid-range"
|
||||
|
||||
def test_seven_hops_is_distant(self):
|
||||
assert classify_path_distance(7) == "distant"
|
||||
|
||||
def test_large_hop_count_is_distant(self):
|
||||
assert classify_path_distance(20) == "distant"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -821,3 +821,87 @@ class TestPathFinderEdgeCases:
|
||||
|
||||
paths = self.finder.all_shortest_paths(single_node_graph, "A")
|
||||
assert len(paths) == 0 # No paths to other nodes
|
||||
|
||||
|
||||
class TestBidirectionalPathFinding:
|
||||
"""Tests for the directed=False undirected-traversal mode (issue #469)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.finder = PathFinder()
|
||||
# Single directed edge A → B. Reverse query B → A has no directed path.
|
||||
self.digraph = nx.DiGraph()
|
||||
self.digraph.add_edge("A", "B")
|
||||
|
||||
# --- directed=True (default) preserves existing behaviour ---
|
||||
|
||||
def test_bfs_directed_true_reverse_returns_empty(self):
|
||||
"""B→A should find nothing when directed=True (default)."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=True)
|
||||
assert path == []
|
||||
|
||||
def test_dijkstra_directed_true_reverse_returns_empty(self):
|
||||
"""B→A should find nothing when directed=True (default)."""
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=True)
|
||||
assert path == []
|
||||
|
||||
def test_bfs_directed_true_default_arg(self):
|
||||
"""Omitting directed= should behave the same as directed=True."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A")
|
||||
assert path == []
|
||||
|
||||
def test_dijkstra_directed_true_default_arg(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A")
|
||||
assert path == []
|
||||
|
||||
# --- directed=False finds path against edge orientation ---
|
||||
|
||||
def test_bfs_directed_false_reverse_single_edge(self):
|
||||
"""directed=False must find B→A even though only A→B exists."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "B", "A", directed=False)
|
||||
assert path == ["B", "A"]
|
||||
|
||||
def test_dijkstra_directed_false_reverse_single_edge(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "B", "A", directed=False)
|
||||
assert path == ["B", "A"]
|
||||
|
||||
def test_bfs_directed_false_forward_still_works(self):
|
||||
"""directed=False should not break the forward direction."""
|
||||
path = self.finder.bfs_shortest_path(self.digraph, "A", "B", directed=False)
|
||||
assert path == ["A", "B"]
|
||||
|
||||
def test_dijkstra_directed_false_forward_still_works(self):
|
||||
path = self.finder.dijkstra_shortest_path(self.digraph, "A", "B", directed=False)
|
||||
assert path == ["A", "B"]
|
||||
|
||||
# --- multi-hop path where one edge is against the query direction ---
|
||||
|
||||
def test_bfs_directed_false_multihop(self):
|
||||
"""A→B, C→B graph: directed=False lets us find A→B→C (i.e. A→C via B)."""
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "B")
|
||||
g.add_edge("C", "B") # oriented towards B, not away from it
|
||||
# undirected view: A-B-C, so A→C path exists
|
||||
path = self.finder.bfs_shortest_path(g, "A", "C", directed=False)
|
||||
assert path[0] == "A" and path[-1] == "C"
|
||||
assert "B" in path
|
||||
|
||||
def test_dijkstra_directed_false_multihop(self):
|
||||
g = nx.DiGraph()
|
||||
g.add_edge("A", "B")
|
||||
g.add_edge("C", "B")
|
||||
path = self.finder.dijkstra_shortest_path(g, "A", "C", directed=False)
|
||||
assert path[0] == "A" and path[-1] == "C"
|
||||
assert "B" in path
|
||||
|
||||
# --- PathResponse.directed field ---
|
||||
|
||||
def test_path_response_directed_field_exists(self):
|
||||
"""PathResponse must carry a directed field."""
|
||||
from semantica.explorer.schemas import PathResponse
|
||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"], directed=False)
|
||||
assert r.directed is False
|
||||
|
||||
def test_path_response_directed_field_defaults_true(self):
|
||||
from semantica.explorer.schemas import PathResponse
|
||||
r = PathResponse(source="A", target="B", algorithm="bfs", path=["A", "B"])
|
||||
assert r.directed is True
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Tests for PR #482: DeepSeekProvider switch from deepseek SDK to openai SDK."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from pydantic import BaseModel
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
|
||||
|
||||
|
||||
class TestDeepSeekProviderInit(unittest.TestCase):
|
||||
"""Tests for DeepSeekProvider.__init__ and _init_client after PR #482."""
|
||||
|
||||
def setUp(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
self.DeepSeekProvider = DeepSeekProvider
|
||||
|
||||
def test_base_url_set_on_init(self):
|
||||
"""self.base_url must be set before _init_client is called (PR #482 regression)."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
||||
self.assertTrue(
|
||||
hasattr(provider, "base_url"),
|
||||
"DeepSeekProvider missing self.base_url — causes AttributeError in _init_client",
|
||||
)
|
||||
self.assertEqual(provider.base_url, "https://api.deepseek.com/v1")
|
||||
|
||||
def test_base_url_points_to_v1_endpoint(self):
|
||||
"""base_url must include /v1 so OpenAI SDK resolves /chat/completions correctly."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="fake-key")
|
||||
self.assertIn("/v1", provider.base_url, "base_url must include /v1")
|
||||
|
||||
def test_init_client_uses_openai_not_deepseek(self):
|
||||
"""_init_client must import openai.OpenAI, not deepseek.Client."""
|
||||
mock_openai_cls = MagicMock()
|
||||
mock_openai_instance = MagicMock()
|
||||
mock_openai_cls.return_value = mock_openai_instance
|
||||
|
||||
with patch.dict("sys.modules", {"openai": MagicMock(OpenAI=mock_openai_cls)}):
|
||||
# Re-import to pick up patched sys.modules
|
||||
import importlib
|
||||
import semantica.semantic_extract.providers as providers_mod
|
||||
importlib.reload(providers_mod)
|
||||
DeepSeekProvider = providers_mod.DeepSeekProvider
|
||||
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
|
||||
mock_openai_cls.assert_called_once_with(
|
||||
api_key="sk-test",
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
)
|
||||
self.assertIs(provider.client, mock_openai_instance)
|
||||
|
||||
def test_init_client_no_api_key_leaves_client_none(self):
|
||||
"""Without an API key, client must remain None."""
|
||||
with patch("semantica.semantic_extract.providers.config") as mock_cfg:
|
||||
mock_cfg.get_api_key.return_value = None
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key=None)
|
||||
provider.client = None # simulate _init_client no-op
|
||||
self.assertFalse(provider.is_available())
|
||||
|
||||
def test_init_client_handles_openai_import_error(self):
|
||||
"""If openai is not installed, _init_client must set client=None, not raise."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None # manually simulate ImportError path
|
||||
# Directly call _init_client with openai blocked
|
||||
with patch.dict("sys.modules", {"openai": None}):
|
||||
try:
|
||||
provider._init_client()
|
||||
except Exception as e:
|
||||
self.fail(f"_init_client raised unexpectedly: {e}")
|
||||
self.assertIsNone(provider.client)
|
||||
|
||||
def test_is_available_true_when_client_set(self):
|
||||
"""is_available() returns True when self.client is an OpenAI instance."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = MagicMock()
|
||||
self.assertTrue(provider.is_available())
|
||||
|
||||
def test_is_available_false_when_client_none(self):
|
||||
"""is_available() returns False when self.client is None."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
self.assertFalse(provider.is_available())
|
||||
|
||||
def test_no_deepseek_module_imported(self):
|
||||
"""deepseek module must NOT be imported by _init_client after PR #482."""
|
||||
with patch.object(self.DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = self.DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
blocked = MagicMock()
|
||||
blocked.__spec__ = None
|
||||
with patch.dict("sys.modules", {"deepseek": None}):
|
||||
# _init_client should succeed even if deepseek is completely absent
|
||||
mock_openai = MagicMock()
|
||||
mock_openai.OpenAI.return_value = MagicMock()
|
||||
with patch.dict("sys.modules", {"openai": mock_openai, "deepseek": None}):
|
||||
try:
|
||||
provider._init_client()
|
||||
except Exception as e:
|
||||
self.fail(f"_init_client raised when deepseek absent: {e}")
|
||||
|
||||
|
||||
class TestDeepSeekProviderGenerate(unittest.TestCase):
|
||||
"""Tests for DeepSeekProvider.generate / generate_structured with OpenAI client."""
|
||||
|
||||
def _make_provider(self, api_key="sk-test"):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key=api_key)
|
||||
provider.client = MagicMock()
|
||||
return provider
|
||||
|
||||
def test_generate_uses_chat_completions(self):
|
||||
"""generate() must call client.chat.completions.create."""
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = "hello"
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
result = provider.generate("test prompt")
|
||||
|
||||
provider.client.chat.completions.create.assert_called_once()
|
||||
self.assertEqual(result, "hello")
|
||||
|
||||
def test_generate_passes_model(self):
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = "x"
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
provider.generate("p", model="deepseek-reasoner")
|
||||
kwargs = provider.client.chat.completions.create.call_args[1]
|
||||
self.assertEqual(kwargs["model"], "deepseek-reasoner")
|
||||
|
||||
def test_generate_structured_returns_parsed_json(self):
|
||||
provider = self._make_provider()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.choices[0].message.content = '{"key": "value"}'
|
||||
provider.client.chat.completions.create.return_value = mock_resp
|
||||
|
||||
result = provider.generate_structured("test prompt")
|
||||
self.assertEqual(result, {"key": "value"})
|
||||
|
||||
def test_generate_raises_without_client(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
provider.generate("prompt")
|
||||
|
||||
def test_generate_structured_raises_without_client(self):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider, ProcessingError
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = None
|
||||
|
||||
with self.assertRaises(ProcessingError):
|
||||
provider.generate_structured("prompt")
|
||||
|
||||
|
||||
class TestDeepSeekInstructorPath(unittest.TestCase):
|
||||
"""Tests for generate_typed instructor path with DeepSeekProvider (OpenAI client)."""
|
||||
|
||||
def _make_provider(self, api_key="sk-test"):
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
from unittest.mock import MagicMock
|
||||
from openai import OpenAI
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key=api_key)
|
||||
# After PR #482, client is an OpenAI instance
|
||||
mock_client = MagicMock(spec=OpenAI)
|
||||
provider.client = mock_client
|
||||
return provider
|
||||
|
||||
def test_generate_typed_instructor_openai_isinstance_check(self):
|
||||
"""After PR #482, client is OpenAI, so instructor path must use from_openai."""
|
||||
from openai import OpenAI
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
provider.client = MagicMock(spec=OpenAI)
|
||||
|
||||
self.assertIsInstance(
|
||||
provider.client, OpenAI,
|
||||
"client must be OpenAI instance for instructor isinstance check to pass",
|
||||
)
|
||||
|
||||
|
||||
class TestVerboseModeAssignment(unittest.TestCase):
|
||||
"""Tests for verbose_mode assignment fix in BaseProvider.generate_typed (commit eec3e88)."""
|
||||
|
||||
def _make_openai_provider(self):
|
||||
from semantica.semantic_extract.providers import OpenAIProvider
|
||||
with patch.object(OpenAIProvider, "_init_client", return_value=None):
|
||||
provider = OpenAIProvider(api_key="sk-test")
|
||||
provider.client = MagicMock()
|
||||
return provider
|
||||
|
||||
def test_generate_typed_no_verbose_no_name_error(self):
|
||||
"""generate_typed must not raise NameError for verbose_mode when verbose not passed."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_instructor = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.return_value = Schema(value="ok")
|
||||
mock_instructor.from_openai.return_value = mock_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
try:
|
||||
result = provider.generate_typed("prompt", Schema)
|
||||
except NameError as e:
|
||||
self.fail(f"NameError for verbose_mode: {e}")
|
||||
except Exception:
|
||||
pass # other errors are OK — we only care NameError is gone
|
||||
|
||||
def test_generate_typed_verbose_true_prints(self):
|
||||
"""When verbose=True, generate_typed must print the confirmation line."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema, verbose=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
output = captured.getvalue()
|
||||
# verbose_mode=True should trigger the print statement
|
||||
self.assertIn("generate_typed", output)
|
||||
|
||||
def test_generate_typed_verbose_false_no_print(self):
|
||||
"""When verbose=False (default), generate_typed must not print anything."""
|
||||
provider = self._make_openai_provider()
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertEqual(captured.getvalue(), "")
|
||||
|
||||
def test_generate_typed_verbose_from_config(self):
|
||||
"""verbose_mode must also respect config-level verbose setting."""
|
||||
provider = self._make_openai_provider()
|
||||
provider.config["verbose"] = True
|
||||
|
||||
class Schema(BaseModel):
|
||||
value: str
|
||||
|
||||
mock_schema_instance = Schema(value="ok")
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = mock_schema_instance
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("skip")
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
import io
|
||||
captured = io.StringIO()
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
with patch("sys.stdout", captured):
|
||||
try:
|
||||
provider.generate_typed("prompt", Schema)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.assertIn("generate_typed", captured.getvalue())
|
||||
|
||||
|
||||
class TestDeepSeekGenerateTypedInstructorIntegration(unittest.TestCase):
|
||||
"""Integration-style tests: DeepSeekProvider.generate_typed with instructor."""
|
||||
|
||||
def test_generate_typed_deepseek_uses_openai_client_for_instructor(self):
|
||||
"""generate_typed instructor path for DeepSeek must reuse the OpenAI client."""
|
||||
from semantica.semantic_extract.providers import DeepSeekProvider
|
||||
from openai import OpenAI
|
||||
|
||||
with patch.object(DeepSeekProvider, "_init_client", return_value=None):
|
||||
provider = DeepSeekProvider(api_key="sk-test")
|
||||
mock_openai_client = MagicMock(spec=OpenAI)
|
||||
provider.client = mock_openai_client
|
||||
|
||||
class Schema(BaseModel):
|
||||
label: str
|
||||
|
||||
mock_instructor = MagicMock()
|
||||
mock_ic_client = MagicMock()
|
||||
mock_ic_client.chat.completions.create.return_value = Schema(label="ok")
|
||||
mock_instructor.from_openai.return_value = mock_ic_client
|
||||
mock_instructor.from_provider.side_effect = Exception("no from_provider")
|
||||
mock_instructor.Mode.JSON = "json"
|
||||
mock_instructor.Mode.TOOLS = "tools"
|
||||
|
||||
with patch("semantica.semantic_extract.providers.instructor", mock_instructor):
|
||||
result = provider.generate_typed("classify this", Schema)
|
||||
|
||||
# Must have called from_openai with the existing client (not a fresh one)
|
||||
mock_instructor.from_openai.assert_called_once_with(
|
||||
mock_openai_client, mode="json"
|
||||
)
|
||||
self.assertEqual(result.label, "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user