mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-30 04:40:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ffed78fd9 | ||
|
|
f06de0dab2 | ||
|
|
dd016744ce | ||
|
|
7884d71e23 | ||
|
|
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 |
@@ -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/
|
||||
|
||||
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Feature: Distance Intelligence** (closes #502 by @KaifAhmad1):
|
||||
- **Context layer** — `ContextGraph.get_neighbors()` gains `include_distance_metadata=False`; when enabled adds `distance_band`, `confidence_decay`, and `path_to_anchor` per result. New `get_neighbor_distances()` returns neighbors sorted by `(hop, -decay)` with optional `min_confidence` filter. `AgentContext.retrieve()` / `find_precedents()` accept `anchor_node`, `max_hops`, `proximity_weight`, `min_confidence_decay` and blend graph proximity with semantic score as `combined_score = (1 − w) × semantic + w × proximity`.
|
||||
- **Path enrichment (FR-4)** — `GET /api/graph/node/{id}/path` now returns `semantic_similarity`, `path_coherence_score`, `confidence_decay` (O(L) via pre-built edge-weight index), `bottleneck_node`, `alternative_path_count`, and `interpretation`. All fields optional; zero breaking changes.
|
||||
- **Distance matrix (FR-6)** — `POST /api/graph/distance-matrix` accepts up to 50 nodes and metric `hops | weighted | semantic`. Returns N × N matrix (upper-triangle computed, lower mirrored), unreachable pairs, and `computation_time_ms`.
|
||||
- **Semantic neighborhood (FR-3 backend)** — `GET /api/graph/node/{id}/semantic-neighborhood?top_k=N` returns the N most similar nodes with `id`, `type`, `content`, `similarity`, `hop_distance`.
|
||||
- **Causal distance (FR-8)** — `GET /api/decisions/causal-distance?source=&target=` traverses only causal-typed edges and returns `CausalDistanceReport` with path, hop count, `confidence_decay`, `weakest_link`, and interpretation.
|
||||
- **Temporal distance history (FR-9)** — `GET /api/temporal/distance-history` samples 11 evenly-spaced snapshots across the graph's time range and emits `convergence | divergence | disconnected | reconnected` events.
|
||||
- **Distance-enriched export (FR-10)** — `POST /api/export/distance-enriched` streams pairwise hop/weighted/semantic/band/centrality metrics as CSV or JSONL. `node_subset` capped at 200 nodes.
|
||||
- **Explorer UI** — Path inspector panel (`GraphInspectorPanel.tsx`) shows a distance band chip, progress-bar metric cards (decay, similarity, coherence), bottleneck node highlight, and interpretation text. Toolbar gains Ego Mode (client-side BFS depth-of-field fading, depth slider 1–8), Structural overlay (edges colored by hop distance), Semantic overlay (edges colored by cosine similarity), and Heatmap (nodes colored green → red by hop distance). Ego and heatmap share a single merged `useEffect` to prevent `restoreNodeColors()` races.
|
||||
- **Tests** — 57 new tests in `tests/context/test_distance_intelligence.py`; 18 targeted regression tests in `tests/_smoke_review_fixes.py`.
|
||||
|
||||
- **Fix: Distance Intelligence — code review regressions** (PR #502 follow-up by @KaifAhmad1):
|
||||
- `GraphWorkspace.tsx` semantic fetch used `?limit=50`; corrected to `?top_k=50` to match the backend param (bug_001). Response type widened to full `SemanticNeighborhoodResponse` shape (bug_002).
|
||||
- `ContextGraph.get_neighbors()` was embedding distance metadata unconditionally, breaking existing callers; gated behind `include_distance_metadata=False` default (bug_003).
|
||||
- `weakest_link` dict key standardised from `weight` → `edge_weight` across `CausalChainAnalyzer` and `CausalDistanceReport` (bug_004).
|
||||
- Temporal distance history sampling replaced `timetuple()[:6]` reconstruction with `min_bound + timedelta(seconds=...)` (bug_005).
|
||||
- Confidence decay in `find_path` was O(E × L); replaced with a single O(E) edge-weight index built before the hop loop, with undirected mirroring (bug_006).
|
||||
- `AgentContext._apply_proximity_metadata()` was overwriting the original record `"id"` with the graph node id; stored as `"graph_node_id"` instead (bug_007).
|
||||
- Path highlight sweep animation used a shared `sweepTimer`; stale callbacks fired after cancellation. Added `sweepGeneration` counter — callbacks no-op if generation no longer matches (bug_008).
|
||||
- `POST /api/export/distance-enriched` now rejects `node_subset` larger than 200 nodes with HTTP 413 (sec_001).
|
||||
- `POST /api/graph/distance-matrix` now computes only the upper triangle and mirrors results, halving computation cost (sec_002).
|
||||
- Ego mode and heatmap `useEffect` hooks merged into one to eliminate concurrent `restoreNodeColors()` race (qual_001).
|
||||
- Bare `except Exception: pass` blocks in `find_path` and `semantic_neighborhood` replaced with `logger.debug(...)` (qual_002).
|
||||
- Duplicated `_distance_band()` static method removed from `CausalChainAnalyzer` and `AgentContext`; both now use `classify_path_distance` from `semantica.utils.helpers` (qual_003).
|
||||
|
||||
- **Feature: Graph Workspace declutter + calmer structural exploration** (PR #483 by @ZohaibHassan16, follow-up by @KaifAhmad1):
|
||||
- Added a calmer default presentation for dense graphs: reduced label pressure, stronger inactive-state muting, and tuned zoom-tier visibility to improve readability during overview and structure navigation.
|
||||
- Added display-edge aggregation with raw-edge bundle metadata retention, enabling cleaner visuals while preserving drill-down context for selected edges.
|
||||
- Added grouped community view and neighborhood collapse/expand controls for high-degree local structures in Graph Workspace and Neighborhood panel flows.
|
||||
- Extended graph selection/runtime state with display-state metadata (`groupedViewAvailable`, visible/collapsed neighbor counts, aggregated edge descriptors) for plugin and panel introspection.
|
||||
- Added regression coverage for `resolveDisplayGraph` behavior in `explorer/tests/graphSceneState.display.test.ts`:
|
||||
- parallel-edge aggregation in full view
|
||||
- collapse behavior preserving active-path neighbors
|
||||
- grouped community-node/community-edge projection behavior
|
||||
- Follow-up merge resolution synced the PR branch with `main` after Explorer path migration (`semantica-explorer` -> `explorer`) and preserved PR #483 behavior in conflicted Graph Workspace files.
|
||||
|
||||
- **Fix: DeepSeekProvider now uses OpenAI SDK instead of unmaintained deepseek SDK** (closes #482, PR #482 by @liling, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The `deepseek` PyPI package has no `deepseek.Client`, causing `AttributeError` on every `DeepSeekProvider` instantiation. The DeepSeek API is OpenAI-compatible, so the `openai` SDK is the correct client.
|
||||
- **`_init_client` rewritten**: Replaced `import deepseek; deepseek.Client(api_key=...)` with `from openai import OpenAI; OpenAI(api_key=..., base_url=self.base_url)`, matching the pattern already used by `NovitaProvider`.
|
||||
- **`self.base_url` added to `__init__`**: Set to `"https://api.deepseek.com/v1"` (with `/v1` suffix required by the OpenAI SDK for correct endpoint resolution). This was missing from the original PR, causing a second `AttributeError` at `_init_client` call time.
|
||||
- **`generate_typed` `verbose_mode` fix**: `verbose_mode` was referenced before assignment inside the instructor path. Added assignment `verbose_mode = kwargs.get("verbose", False) or self.config.get("verbose", False)` at the correct scope.
|
||||
- **`pyproject.toml` updated**: `llm-deepseek` extra now declares `openai>=1.0.0` instead of the defunct `deepseek>=0.1.0`.
|
||||
- **Warning message updated**: `_init_client` ImportError warning now references the `openai` library and `llm-openai` extra.
|
||||
- **Instructor path improved**: Since `self.client` is now an `OpenAI` instance, the `isinstance(self.client, OpenAI)` check in `generate_typed` passes correctly, avoiding a redundant second client construction.
|
||||
- 19 new tests in `tests/semantic_extract/test_pr482_deepseek_openai.py` across five suites: `TestDeepSeekProviderInit` (8 — covers `base_url`, OpenAI instantiation, no `deepseek` import, ImportError handling, `is_available`), `TestDeepSeekProviderGenerate` (5 — `generate`, `generate_structured`, no-client error paths), `TestDeepSeekInstructorPath` (1 — `isinstance` check), `TestVerboseModeAssignment` (4 — no NameError, verbose kwarg, config verbose, no-print default), `TestDeepSeekGenerateTypedInstructorIntegration` (1 — end-to-end instructor path reuses existing client).
|
||||
|
||||
- **Performance: Indexed search for large knowledge graphs** (closes #467, PR #481 by @ZohaibHassan16, review fixes by @KaifAhmad1):
|
||||
- **Root cause**: The previous `GraphSession.search()` ran a full O(n) scan over all nodes per query, serializing every node's properties to JSON for string matching. On a 118 k-node graph warm queries took 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`).
|
||||
|
||||
+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
@@ -1,7 +1,8 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
target: string;
|
||||
@@ -16,10 +17,21 @@ export type PathResponse = {
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
// FR-1 distance intelligence enrichment
|
||||
semantic_similarity?: number | null;
|
||||
path_coherence_score?: number | null;
|
||||
confidence_decay?: number | null;
|
||||
bottleneck_node?: string | null;
|
||||
alternative_path_count?: number;
|
||||
interpretation?: string;
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
nodeId: string;
|
||||
inspectableNodeId?: string | null;
|
||||
selectedNodeKind?: GraphSelectedNodeKind;
|
||||
canActivateFocused?: boolean;
|
||||
focusedUnavailableReason?: string | null;
|
||||
predictions: LinkPrediction[];
|
||||
predictionType: string;
|
||||
onPredictionTypeChange: (value: string) => void;
|
||||
@@ -41,6 +53,132 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
/* ─── Path Distance Intelligence Panel ──────────────────────────── */
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
direct: "#3fb950",
|
||||
near: "#79c0ff",
|
||||
"mid-range": "#e3b341",
|
||||
distant: "#ff7b72",
|
||||
};
|
||||
|
||||
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
const hasMetrics =
|
||||
result.confidence_decay != null ||
|
||||
result.semantic_similarity != null ||
|
||||
result.path_coherence_score != null ||
|
||||
result.bottleneck_node ||
|
||||
result.interpretation;
|
||||
if (!hasMetrics) return null;
|
||||
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
{/* distance band + alt paths */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: withAlpha(bandColor, 0.14),
|
||||
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
|
||||
color: bandColor,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{(result.alternative_path_count ?? 0) > 0 && (
|
||||
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* metric grid */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{result.confidence_decay != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
>
|
||||
{(result.confidence_decay * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div
|
||||
style={{
|
||||
...metricBarFillStyle,
|
||||
width: `${result.confidence_decay * 100}%`,
|
||||
background:
|
||||
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.semantic_similarity != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Semantic Sim.</div>
|
||||
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
|
||||
{(result.semantic_similarity * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.path_coherence_score != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Path Coherence</div>
|
||||
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
|
||||
{(result.path_coherence_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.bottleneck_node && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Bottleneck</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: "#e3b341",
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={result.bottleneck_node}
|
||||
>
|
||||
{getNodeLabel(result.bottleneck_node)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* interpretation */}
|
||||
{result.interpretation && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
background: "rgba(88,166,255,0.06)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(88,166,255,0.14)",
|
||||
color: "#a0b4cc",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{result.interpretation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
@@ -78,11 +216,13 @@ function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
bottleneckNodeId,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
bottleneckNodeId?: string | null;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
@@ -105,10 +245,13 @@ function PathFlowViz({
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={`Focus: ${nodeId}`}
|
||||
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
...(nodeId === bottleneckNodeId
|
||||
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||
@@ -148,6 +291,10 @@ function PathFlowViz({
|
||||
|
||||
export function GraphInspectorPanel({
|
||||
nodeId,
|
||||
inspectableNodeId,
|
||||
selectedNodeKind = "none",
|
||||
canActivateFocused = false,
|
||||
focusedUnavailableReason = null,
|
||||
predictions,
|
||||
predictionType,
|
||||
onPredictionTypeChange,
|
||||
@@ -173,7 +320,38 @@ export function GraphInspectorPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(nodeId) as {
|
||||
const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null;
|
||||
const directlyInspectable = graph.hasNode(nodeId);
|
||||
const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId;
|
||||
const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable;
|
||||
|
||||
if (!effectiveNodeId) {
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const attributes = graph.getNodeAttributes(effectiveNodeId) as {
|
||||
color?: string;
|
||||
content?: string;
|
||||
label?: string;
|
||||
@@ -196,12 +374,26 @@ export function GraphInspectorPanel({
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ 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>
|
||||
@@ -226,7 +418,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 }} />
|
||||
@@ -234,10 +426,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>
|
||||
@@ -259,15 +451,19 @@ export function GraphInspectorPanel({
|
||||
placeholder="Target node ID"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<>
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
bottleneckNodeId={pathResult.bottleneck_node}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<PathDistanceIntelPanel result={pathResult} />
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
@@ -378,6 +574,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",
|
||||
@@ -505,3 +709,41 @@ const pathEdgeLabelStyle: CSSProperties = {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const metricCardStyle: CSSProperties = {
|
||||
background: "rgba(0,0,0,0.18)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
const metricLabelStyle: CSSProperties = {
|
||||
color: "rgba(88,166,255,0.65)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const metricValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#e6edf3",
|
||||
};
|
||||
|
||||
const metricBarTrackStyle: CSSProperties = {
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
overflow: "hidden",
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const metricBarFillStyle: CSSProperties = {
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
transition: "width 300ms ease",
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState }
|
||||
import { batchMergeEdges, batchMergeNodes, clearGraph, graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
|
||||
import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import { resolveDisplayGraph } from "./graphSceneState";
|
||||
import {
|
||||
chooseColorAccessor,
|
||||
colorForNodeKey,
|
||||
@@ -41,6 +42,7 @@ const STAGE_EFFECTS_STATE: GraphEffectsState = {
|
||||
lensMode: "neighborhood",
|
||||
effectQuality: "bounded",
|
||||
};
|
||||
const EMPTY_PATH: string[] = [];
|
||||
|
||||
const socketProtocol = () => (window.location.protocol === "https:" ? "wss:" : "ws:");
|
||||
|
||||
@@ -67,6 +69,10 @@ function buildSelectedNodeState(nodeId: string): GraphSelectedNodeState | null {
|
||||
valid_until: attributes.valid_until ?? null,
|
||||
properties: attributes.properties ?? {},
|
||||
neighborCount: graph.neighbors(nodeId).length,
|
||||
visibleNeighborCount: graph.neighbors(nodeId).length,
|
||||
collapsedNeighborCount: 0,
|
||||
isNeighborhoodCollapsed: false,
|
||||
canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +119,10 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
const prevActiveIdsRef = useRef<Set<string>>(new Set());
|
||||
const [graphVersion, setGraphVersion] = useState(0);
|
||||
const [runtimeLayoutSource, setRuntimeLayoutSource] = useState<GraphLayoutSource>(snapshot?.summary.layoutSource ?? "runtime");
|
||||
const displayResult = useMemo(
|
||||
() => resolveDisplayGraph(selectedNodeId, activePath, EMPTY_PATH, viewMode, { aggregationEnabled: true }),
|
||||
[activePath, graphVersion, selectedNodeId, viewMode],
|
||||
);
|
||||
|
||||
const stageSignature = useMemo(() => (snapshot ? `${snapshot.fetchedAt}:${snapshot.summary.nodeCount}:${snapshot.summary.edgeCount}` : null), [snapshot]);
|
||||
|
||||
@@ -447,9 +457,16 @@ export const GraphRuntimeStage = forwardRef<GraphStageHandle, GraphRuntimeStageP
|
||||
<SigmaSceneAdapter
|
||||
ref={sceneRef}
|
||||
onNodeSelect={onNodeSelect}
|
||||
graphVersion={graphVersion}
|
||||
graphReady={Boolean(snapshot)}
|
||||
displayGraph={displayResult.graph}
|
||||
displayMeta={displayResult.meta}
|
||||
displayState={displayResult.state}
|
||||
selectedEdgeId=""
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusedNodeId={viewMode === "focused" ? selectedNodeId : ""}
|
||||
activePath={activePath}
|
||||
activePathEdgeIds={EMPTY_PATH}
|
||||
effectsState={STAGE_EFFECTS_STATE}
|
||||
isLayoutRunning={isLayoutRunning}
|
||||
onLayoutRunningChange={onLayoutRunningChange}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,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]);
|
||||
@@ -555,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
|
||||
@@ -631,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,13 +1,37 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
const SWEEP_TICKS = 6;
|
||||
const SWEEP_INTERVAL_MS = 60;
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepGeneration = 0;
|
||||
|
||||
function cancelSweep() {
|
||||
sweepGeneration++;
|
||||
if (sweepTimer !== null) {
|
||||
clearTimeout(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSweep(sigma: { refresh: () => void }, tick: number, gen: number) {
|
||||
if (tick >= SWEEP_TICKS) return;
|
||||
sweepTimer = setTimeout(() => {
|
||||
if (gen !== sweepGeneration) return;
|
||||
sigma.refresh();
|
||||
scheduleSweep(sigma, tick + 1, gen);
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
detach: (context) => {
|
||||
cancelSweep();
|
||||
lastPathSignature = "";
|
||||
context.sigma.refresh();
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
cancelSweep();
|
||||
context.sigma.refresh();
|
||||
|
||||
// Animate intermediate nodes lighting up sequentially
|
||||
if (interactionState.activePath.length > 2) {
|
||||
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
export function createSearchFocusBehavior(): GraphBehavior {
|
||||
let lastFocusedNodeId = "";
|
||||
let lastSelectedNodeId = "";
|
||||
let lastViewMode = "";
|
||||
|
||||
return {
|
||||
id: "search-focus",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
lastFocusedNodeId = "";
|
||||
lastSelectedNodeId = "";
|
||||
lastViewMode = "";
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
if (!nextFocusedNodeId || nextFocusedNodeId === lastFocusedNodeId) {
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
const nextSelectedNodeId = interactionState.selectedNodeId;
|
||||
const nextViewMode = interactionState.viewMode;
|
||||
if (nextViewMode !== lastViewMode) {
|
||||
lastViewMode = nextViewMode;
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
return;
|
||||
}
|
||||
if (!nextSelectedNodeId || nextSelectedNodeId === lastSelectedNodeId) {
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
return;
|
||||
}
|
||||
|
||||
lastFocusedNodeId = nextFocusedNodeId;
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
lastSelectedNodeId = nextSelectedNodeId;
|
||||
lastViewMode = nextViewMode;
|
||||
context.dispatchAction({
|
||||
type: nextViewMode === "grouped" ? "centerGroupedSelection" : "centerSelection",
|
||||
nodeId: nextSelectedNodeId,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { GraphCameraState, GraphInteractionState } from "../types";
|
||||
|
||||
export type GraphBehaviorActionRequest =
|
||||
| { type: "fitView" }
|
||||
| { type: "focusNode"; nodeId: string };
|
||||
| { type: "focusNode"; nodeId: string }
|
||||
| { type: "centerSelection"; nodeId: string }
|
||||
| { type: "centerGroupedSelection"; nodeId: string };
|
||||
|
||||
export interface GraphBehaviorContext {
|
||||
sigma: Sigma;
|
||||
@@ -17,6 +19,8 @@ export interface GraphBehaviorContext {
|
||||
onNodeSelectionChange: (nodeId: string) => void;
|
||||
onEdgeSelectionChange: (edgeId: string) => void;
|
||||
focusNodeInView: (nodeId: string) => void;
|
||||
centerSelectionInView: (nodeId: string) => void;
|
||||
centerGroupedSelectionInView: (nodeId: string) => void;
|
||||
fitCurrentView: () => void;
|
||||
dispatchAction: (action: GraphBehaviorActionRequest) => void;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
import type { GraphViewMode } from "../types";
|
||||
|
||||
export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
let lastViewMode: "focused" | "full" | null = null;
|
||||
let lastViewMode: GraphViewMode | null = null;
|
||||
|
||||
return {
|
||||
id: "view-mode-switch",
|
||||
@@ -15,9 +16,10 @@ export function createViewModeSwitchBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastViewMode = interactionState.viewMode;
|
||||
const nextFocusedNodeId = interactionState.focusedNodeId;
|
||||
|
||||
if (interactionState.focusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: interactionState.focusedNodeId });
|
||||
if (interactionState.viewMode === "focused" && nextFocusedNodeId) {
|
||||
context.dispatchAction({ type: "focusNode", nodeId: nextFocusedNodeId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ Production Use Cases:
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .agent_memory import AgentMemory
|
||||
from .context_retriever import ContextRetriever, RetrievedContext
|
||||
@@ -507,6 +508,10 @@ class AgentContext:
|
||||
include_relationships: bool = False,
|
||||
expand_graph: bool = True,
|
||||
deduplicate: bool = True,
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -560,17 +565,33 @@ class AgentContext:
|
||||
**kwargs,
|
||||
)
|
||||
# Convert RetrievedContext to dicts
|
||||
return [
|
||||
result_dicts = [
|
||||
self._context_to_dict(r, include_entities, include_relationships)
|
||||
for r in results
|
||||
]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
else:
|
||||
# Simple RAG: Use AgentMemory (vector + memory)
|
||||
results = self._memory.retrieve(
|
||||
query, max_results=max_results, min_score=min_score, **kwargs
|
||||
)
|
||||
# Convert to dicts
|
||||
return [self._memory_to_dict(r) for r in results]
|
||||
result_dicts = [self._memory_to_dict(r) for r in results]
|
||||
return self._apply_proximity_metadata(
|
||||
result_dicts,
|
||||
anchor_node=anchor_node,
|
||||
max_hops=max_hops,
|
||||
proximity_weight=proximity_weight,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
def query_with_reasoning(
|
||||
self,
|
||||
@@ -814,6 +835,77 @@ class AgentContext:
|
||||
|
||||
return result
|
||||
|
||||
def _apply_proximity_metadata(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
anchor_node: Optional[str] = None,
|
||||
max_hops: Optional[int] = None,
|
||||
proximity_weight: float = 0.0,
|
||||
min_confidence_decay: float = 0.0,
|
||||
max_results: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enrich retrieval results with graph distance from an anchor node."""
|
||||
if not anchor_node or not self.knowledge_graph:
|
||||
return results
|
||||
if not hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
return results
|
||||
|
||||
search_hops = max_hops if max_hops is not None else 10
|
||||
distances = self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_node,
|
||||
hops=search_hops,
|
||||
min_confidence=min_confidence_decay,
|
||||
)
|
||||
by_node_id = {item.get("id"): item for item in distances}
|
||||
if anchor_node:
|
||||
by_node_id[anchor_node] = {
|
||||
"id": anchor_node,
|
||||
"hop": 0,
|
||||
"confidence_decay": 1.0,
|
||||
"distance_band": "direct",
|
||||
"path_to_anchor": [anchor_node],
|
||||
}
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata") or {}
|
||||
result_id = (
|
||||
result.get("id")
|
||||
or metadata.get("node_id")
|
||||
or metadata.get("id")
|
||||
or metadata.get("memory_id")
|
||||
)
|
||||
distance = by_node_id.get(result_id)
|
||||
if not distance:
|
||||
if max_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
enriched.append(result)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop")
|
||||
if max_hops is not None and hop_distance is not None and hop_distance > max_hops:
|
||||
continue
|
||||
|
||||
proximity_score = 1.0 if hop_distance == 0 else 1.0 / float(hop_distance or 1)
|
||||
score = float(result.get("score", 0.0))
|
||||
bounded_weight = min(max(float(proximity_weight), 0.0), 1.0)
|
||||
combined_score = (1.0 - bounded_weight) * score + bounded_weight * proximity_score
|
||||
enriched_result = {
|
||||
**result,
|
||||
"graph_node_id": result_id,
|
||||
"hop_distance": hop_distance,
|
||||
"confidence_decay": distance.get("confidence_decay"),
|
||||
"distance_band": distance.get("distance_band"),
|
||||
"path_to_anchor": distance.get("path_to_anchor"),
|
||||
"proximity_score": proximity_score,
|
||||
"combined_score": combined_score,
|
||||
}
|
||||
enriched.append(enriched_result)
|
||||
|
||||
if proximity_weight > 0:
|
||||
enriched.sort(key=lambda item: item.get("combined_score", item.get("score", 0.0)), reverse=True)
|
||||
return enriched[:max_results] if max_results is not None else enriched
|
||||
|
||||
def _memory_to_dict(self, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert memory result to dict."""
|
||||
return {
|
||||
@@ -2228,7 +2320,10 @@ class AgentContext:
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
use_kg_features: bool = True,
|
||||
similarity_weights: Optional[Dict[str, float]] = None
|
||||
similarity_weights: Optional[Dict[str, float]] = None,
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
) -> List[Decision]:
|
||||
"""
|
||||
Find precedents using advanced KG and vector store features.
|
||||
@@ -2248,7 +2343,7 @@ class AgentContext:
|
||||
|
||||
try:
|
||||
if hasattr(self._decision_query, 'find_precedents_hybrid'):
|
||||
return self._decision_query.find_precedents_hybrid(
|
||||
precedents = self._decision_query.find_precedents_hybrid(
|
||||
scenario=scenario,
|
||||
category=category,
|
||||
limit=limit,
|
||||
@@ -2257,11 +2352,116 @@ class AgentContext:
|
||||
)
|
||||
else:
|
||||
# Fallback to basic method
|
||||
return self.find_precedents(scenario, category, limit)
|
||||
precedents = self.find_precedents(scenario, category, limit)
|
||||
return self._apply_causal_proximity_to_precedents(
|
||||
precedents,
|
||||
anchor_decision_id=anchor_decision_id,
|
||||
max_causal_hops=max_causal_hops,
|
||||
min_confidence_decay=min_confidence_decay,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to find advanced precedents ({type(e).__name__})")
|
||||
return []
|
||||
|
||||
|
||||
def _apply_causal_proximity_to_precedents(
|
||||
self,
|
||||
precedents: List[Decision],
|
||||
anchor_decision_id: Optional[str] = None,
|
||||
max_causal_hops: Optional[int] = None,
|
||||
min_confidence_decay: float = 0.0,
|
||||
limit: int = 10,
|
||||
) -> List[Decision]:
|
||||
"""Attach causal-distance metadata to precedents and optionally filter."""
|
||||
if not anchor_decision_id or not self.knowledge_graph:
|
||||
return precedents
|
||||
|
||||
causal_types = ["causes", "influences", "leads_to", "supports"]
|
||||
max_hops = max_causal_hops if max_causal_hops is not None else 10
|
||||
distance_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
if hasattr(self.knowledge_graph, "get_neighbor_distances"):
|
||||
for item in self.knowledge_graph.get_neighbor_distances(
|
||||
anchor_decision_id,
|
||||
hops=max_hops,
|
||||
relationship_types=causal_types,
|
||||
min_confidence=min_confidence_decay,
|
||||
):
|
||||
distance_by_id[item.get("id")] = item
|
||||
|
||||
annotated: List[Decision] = []
|
||||
for decision in precedents:
|
||||
decision_id = getattr(decision, "decision_id", None)
|
||||
distance = distance_by_id.get(decision_id)
|
||||
if distance is None and hasattr(self.knowledge_graph, "trace_decision_causality"):
|
||||
distance = self._distance_from_causality_trace(anchor_decision_id, decision_id, max_hops)
|
||||
|
||||
if distance is None:
|
||||
if max_causal_hops is not None or min_confidence_decay > 0.0:
|
||||
continue
|
||||
setattr(decision, "causal_hop_distance", None)
|
||||
setattr(decision, "path_confidence_decay", None)
|
||||
setattr(decision, "distance_band", None)
|
||||
annotated.append(decision)
|
||||
continue
|
||||
|
||||
hop_distance = distance.get("hop", distance.get("hop_count"))
|
||||
confidence_decay = distance.get("confidence_decay")
|
||||
if max_causal_hops is not None and hop_distance is not None and hop_distance > max_causal_hops:
|
||||
continue
|
||||
if confidence_decay is not None and confidence_decay < min_confidence_decay:
|
||||
continue
|
||||
|
||||
setattr(decision, "causal_hop_distance", hop_distance)
|
||||
setattr(decision, "path_confidence_decay", confidence_decay)
|
||||
setattr(decision, "distance_band", distance.get("distance_band"))
|
||||
annotated.append(decision)
|
||||
|
||||
annotated.sort(
|
||||
key=lambda decision: (
|
||||
getattr(decision, "causal_hop_distance", None) is None,
|
||||
getattr(decision, "causal_hop_distance", 10**9) or 10**9,
|
||||
-(getattr(decision, "path_confidence_decay", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return annotated[:limit]
|
||||
|
||||
def _distance_from_causality_trace(
|
||||
self,
|
||||
anchor_decision_id: str,
|
||||
target_decision_id: Optional[str],
|
||||
max_hops: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Infer anchor-to-target distance from ContextGraph causality reports."""
|
||||
if not target_decision_id:
|
||||
return None
|
||||
try:
|
||||
chains = self.knowledge_graph.trace_decision_causality(target_decision_id, max_depth=max_hops)
|
||||
except Exception:
|
||||
return None
|
||||
best: Optional[Dict[str, Any]] = None
|
||||
for chain in chains:
|
||||
hops = chain.get("hops", chain) if isinstance(chain, dict) else chain
|
||||
if not hops:
|
||||
continue
|
||||
starts_at_anchor = hops[0].get("from") == anchor_decision_id
|
||||
ends_at_target = hops[-1].get("to") == target_decision_id
|
||||
if starts_at_anchor and ends_at_target:
|
||||
candidate = {
|
||||
"hop_count": len(hops),
|
||||
"confidence_decay": chain.get("confidence_decay") if isinstance(chain, dict) else None,
|
||||
"distance_band": chain.get("distance_band") if isinstance(chain, dict) else None,
|
||||
}
|
||||
if candidate["confidence_decay"] is None:
|
||||
decay = 1.0
|
||||
for hop in hops:
|
||||
decay *= float(hop.get("edge_weight", 1.0))
|
||||
candidate["confidence_decay"] = decay
|
||||
if candidate["distance_band"] is None:
|
||||
candidate["distance_band"] = classify_path_distance(candidate["hop_count"])
|
||||
if best is None or candidate["hop_count"] < best["hop_count"]:
|
||||
best = candidate
|
||||
return best
|
||||
|
||||
def analyze_decision_influence(self, decision_id: str, max_depth: int = 3) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze decision influence using advanced graph algorithms.
|
||||
|
||||
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Set
|
||||
from collections import deque
|
||||
|
||||
from ..graph_store import GraphStore
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
from .decision_models import Decision
|
||||
|
||||
@@ -677,3 +678,102 @@ class CausalChainAnalyzer:
|
||||
else:
|
||||
decisions.sort(key=lambda d: d.metadata.get("causal_distance", 0))
|
||||
return decisions
|
||||
|
||||
def interpret_causal_distance(
|
||||
self,
|
||||
source_id: str,
|
||||
target_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Traverse only causal-typed edges and return a structured distance report.
|
||||
|
||||
Returns a dict matching CausalDistanceReport with keys:
|
||||
source_id, target_id, causal_path, causal_hop_count,
|
||||
intermediate_decisions, confidence_decay, weakest_link, interpretation
|
||||
"""
|
||||
from collections import deque as _deque
|
||||
|
||||
CAUSAL_TYPES = {"causes", "influences", "leads_to", "supports",
|
||||
"CAUSED", "INFLUENCED", "PRECEDENT_FOR"}
|
||||
|
||||
graph = self.graph_store
|
||||
|
||||
# ContextGraph-native BFS over causal edges
|
||||
if hasattr(graph, "nodes") and hasattr(graph, "_adjacency"):
|
||||
if source_id not in graph.nodes:
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
queue = _deque([(source_id, [source_id], 1.0, None)])
|
||||
visited: Set[str] = {source_id}
|
||||
|
||||
while queue:
|
||||
current_id, path, decay, weakest = queue.popleft()
|
||||
if current_id == target_id:
|
||||
hop_count = len(path) - 1
|
||||
intermediates = [
|
||||
n for n in path[1:-1]
|
||||
if str(getattr(graph.nodes.get(n), "node_type", "")).lower() == "decision"
|
||||
]
|
||||
band = classify_path_distance(hop_count)
|
||||
interp = self._causal_interpretation(hop_count, decay, band)
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": path,
|
||||
"causal_hop_count": hop_count,
|
||||
"intermediate_decisions": intermediates,
|
||||
"confidence_decay": round(decay, 6),
|
||||
"weakest_link": weakest,
|
||||
"interpretation": interp,
|
||||
}
|
||||
|
||||
with graph._lock:
|
||||
outgoing = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing:
|
||||
if edge.edge_type not in CAUSAL_TYPES:
|
||||
continue
|
||||
nxt = edge.target_id
|
||||
if nxt in visited:
|
||||
continue
|
||||
visited.add(nxt)
|
||||
new_decay = decay * edge.weight
|
||||
new_weakest = weakest
|
||||
if weakest is None or edge.weight < weakest.get("edge_weight", 1.0):
|
||||
new_weakest = {"source": current_id, "target": nxt, "edge_weight": edge.weight}
|
||||
queue.append((nxt, path + [nxt], new_decay, new_weakest))
|
||||
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
# GraphStore fallback — return not-reachable; callers can use get_causal_chain instead
|
||||
return self._unreachable_report(source_id, target_id)
|
||||
|
||||
@staticmethod
|
||||
def _causal_interpretation(hop_count: int, decay: float, band: str) -> str:
|
||||
if band == "direct":
|
||||
base = f"Direct cause with confidence {decay:.2f}."
|
||||
elif band == "near":
|
||||
base = (
|
||||
f"Mediated through {hop_count - 1} decision(s); "
|
||||
f"confidence decays to {decay:.2f}"
|
||||
)
|
||||
base += " — moderate evidence." if decay > 0.4 else " — weak evidence."
|
||||
else:
|
||||
base = (
|
||||
f"Distal influence across {hop_count} causal steps; "
|
||||
f"confidence near {decay:.2f} — weak signal."
|
||||
)
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _unreachable_report(source_id: str, target_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"target_id": target_id,
|
||||
"causal_path": [],
|
||||
"causal_hop_count": 0,
|
||||
"intermediate_decisions": [],
|
||||
"confidence_decay": 0.0,
|
||||
"weakest_link": None,
|
||||
"interpretation": "No causal path found between the two nodes.",
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ import uuid
|
||||
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from .entity_linker import EntityLinker
|
||||
|
||||
# Optional imports for advanced features
|
||||
@@ -130,6 +131,13 @@ except ImportError:
|
||||
KG_AVAILABLE = False
|
||||
|
||||
|
||||
class _CausalChain(dict):
|
||||
"""Dict response that still iterates over hops for legacy callers."""
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.get("hops", []))
|
||||
|
||||
|
||||
def _parse_iso_dt(value: str) -> Optional[datetime]:
|
||||
"""Parse an ISO datetime string into a tz-naive UTC datetime.
|
||||
|
||||
@@ -739,6 +747,7 @@ class ContextGraph:
|
||||
min_weight: float = 0.0,
|
||||
skip: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
include_distance_metadata: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get neighbors of a node.
|
||||
@@ -762,11 +771,11 @@ class ContextGraph:
|
||||
|
||||
neighbors: List[Dict[str, Any]] = []
|
||||
visited = {node_id}
|
||||
queue = deque([(node_id, 0)])
|
||||
queue = deque([(node_id, 0, [node_id], 1.0)])
|
||||
rel_filter = set(relationship_types) if relationship_types else None
|
||||
|
||||
while queue:
|
||||
current_id, current_hop = queue.popleft()
|
||||
current_id, current_hop, path_so_far, decay_so_far = queue.popleft()
|
||||
if current_hop >= hops:
|
||||
continue
|
||||
|
||||
@@ -780,26 +789,59 @@ class ContextGraph:
|
||||
if neighbor_id in visited:
|
||||
continue
|
||||
visited.add(neighbor_id)
|
||||
queue.append((neighbor_id, current_hop + 1))
|
||||
next_hop = current_hop + 1
|
||||
next_decay = decay_so_far * edge.weight
|
||||
next_path = path_so_far + [neighbor_id]
|
||||
queue.append((neighbor_id, next_hop, next_path, next_decay))
|
||||
|
||||
node = self.nodes.get(neighbor_id)
|
||||
if not node:
|
||||
continue
|
||||
neighbors.append(
|
||||
{
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": current_hop + 1,
|
||||
}
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type,
|
||||
"content": node.content,
|
||||
"relationship": edge.edge_type,
|
||||
"weight": edge.weight,
|
||||
"hop": next_hop,
|
||||
}
|
||||
if include_distance_metadata:
|
||||
entry["distance_band"] = classify_path_distance(next_hop)
|
||||
entry["confidence_decay"] = next_decay
|
||||
entry["path_to_anchor"] = next_path
|
||||
neighbors.append(entry)
|
||||
|
||||
if limit is not None:
|
||||
return neighbors[skip: skip + limit]
|
||||
return neighbors[skip:]
|
||||
|
||||
def get_neighbor_distances(
|
||||
self,
|
||||
node_id: str,
|
||||
hops: int = 3,
|
||||
relationship_types: Optional[List[str]] = None,
|
||||
min_confidence: float = 0.0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return neighbors with distance metadata, filtered by confidence decay.
|
||||
|
||||
Results are ordered by nearest hop first, then by strongest path confidence.
|
||||
"""
|
||||
neighbors = self.get_neighbors(
|
||||
node_id,
|
||||
hops=hops,
|
||||
relationship_types=relationship_types,
|
||||
include_distance_metadata=True,
|
||||
)
|
||||
filtered = [
|
||||
item for item in neighbors
|
||||
if item.get("confidence_decay", 0.0) >= min_confidence
|
||||
]
|
||||
return sorted(
|
||||
filtered,
|
||||
key=lambda item: (item.get("hop", 0), -item.get("confidence_decay", 0.0)),
|
||||
)
|
||||
|
||||
def query(
|
||||
self, query: str, skip: int = 0, limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -1187,6 +1229,90 @@ class ContextGraph:
|
||||
other_graph, _, target_node_id = self._linked_graphs[link_id]
|
||||
return other_graph, target_node_id
|
||||
|
||||
def cross_graph_path(
|
||||
self,
|
||||
source_node_id: str,
|
||||
target_graph: "ContextGraph",
|
||||
target_node_id: str,
|
||||
max_hops: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find the shortest path across linked ContextGraph instances.
|
||||
"""
|
||||
start = (self.graph_id, source_node_id)
|
||||
goal = (target_graph.graph_id, target_node_id)
|
||||
if source_node_id not in self.nodes or target_node_id not in target_graph.nodes:
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
queue = deque([(self, source_node_id, [start], 0, 1.0, 0)])
|
||||
visited = {start}
|
||||
|
||||
while queue:
|
||||
graph, current_id, path, hop_count, decay, links_used = queue.popleft()
|
||||
current_key = (graph.graph_id, current_id)
|
||||
if current_key == goal:
|
||||
return {
|
||||
"path": path,
|
||||
"hop_count": hop_count,
|
||||
"cross_graph_links_used": links_used,
|
||||
"confidence_decay": decay,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"reachable": True,
|
||||
}
|
||||
if hop_count >= max_hops:
|
||||
continue
|
||||
|
||||
with graph._lock:
|
||||
outgoing_edges = list(graph._adjacency.get(current_id, []))
|
||||
|
||||
for edge in outgoing_edges:
|
||||
marker = graph.nodes.get(edge.target_id)
|
||||
link_id = None
|
||||
if marker and marker.node_type == "cross_graph_link":
|
||||
link_id = marker.metadata.get("link_id")
|
||||
|
||||
if link_id:
|
||||
try:
|
||||
next_graph, next_node_id = graph.navigate_to(link_id)
|
||||
except KeyError:
|
||||
continue
|
||||
next_key = (next_graph.graph_id, next_node_id)
|
||||
next_links_used = links_used + 1
|
||||
else:
|
||||
next_graph, next_node_id = graph, edge.target_id
|
||||
next_key = (graph.graph_id, edge.target_id)
|
||||
next_links_used = links_used
|
||||
|
||||
if next_key in visited:
|
||||
continue
|
||||
visited.add(next_key)
|
||||
queue.append(
|
||||
(
|
||||
next_graph,
|
||||
next_node_id,
|
||||
path + [next_key],
|
||||
hop_count + 1,
|
||||
decay * edge.weight,
|
||||
next_links_used,
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"path": [],
|
||||
"hop_count": 0,
|
||||
"cross_graph_links_used": 0,
|
||||
"confidence_decay": 0.0,
|
||||
"distance_band": classify_path_distance(max_hops + 1),
|
||||
"reachable": False,
|
||||
}
|
||||
|
||||
def resolve_links(self, graphs: Dict[str, "ContextGraph"]) -> int:
|
||||
"""
|
||||
Reconnect cross-graph links after a :meth:`load_from_file` call.
|
||||
@@ -2552,13 +2678,14 @@ class ContextGraph:
|
||||
# Calculate influence scores
|
||||
influence_scores = {}
|
||||
for influenced_id in direct_influence | indirect_influence:
|
||||
score = self._calculate_decision_influence_score(decision_id, influenced_id)
|
||||
influence_scores[influenced_id] = score
|
||||
influence_scores[influenced_id] = self._calculate_decision_influence_score(
|
||||
decision_id, influenced_id
|
||||
)
|
||||
|
||||
# Sort by influence score
|
||||
sorted_influence = sorted(
|
||||
influence_scores.items(),
|
||||
key=lambda x: x[1],
|
||||
key=lambda x: x[1].get("score", 0.0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
@@ -2576,11 +2703,22 @@ class ContextGraph:
|
||||
"direct_influence": [_enrich(did) for did in direct_influence],
|
||||
"indirect_influence": [_enrich(did) for did in indirect_influence],
|
||||
"influence_scores": [
|
||||
{**_enrich(did), "score": score}
|
||||
for did, score in sorted_influence
|
||||
{
|
||||
**_enrich(did),
|
||||
"score": details.get("score", 0.0),
|
||||
"score_breakdown": {
|
||||
"entity_overlap": details.get("entity_score", 0.0),
|
||||
"category_match": details.get("category_score", 0.0),
|
||||
"temporal_proximity": details.get("time_score", 0.0),
|
||||
},
|
||||
"is_direct": did in direct_influence,
|
||||
}
|
||||
for did, details in sorted_influence
|
||||
],
|
||||
"total_influenced": len(influence_scores),
|
||||
"max_influence_score": max(influence_scores.values()) if influence_scores else 0.0
|
||||
"max_influence_score": max(
|
||||
details.get("score", 0.0) for details in influence_scores.values()
|
||||
) if influence_scores else 0.0
|
||||
}
|
||||
|
||||
def get_decision_insights(self) -> Dict[str, Any]:
|
||||
@@ -2677,15 +2815,17 @@ class ContextGraph:
|
||||
|
||||
for cause_id in potential_causes:
|
||||
cause_dec = self._decisions.get(cause_id, {})
|
||||
edge_weight = float(cause_dec.get("confidence", 1.0))
|
||||
hop = {
|
||||
"from": cause_id,
|
||||
"from_scenario": cause_dec.get("scenario", ""),
|
||||
"to": current_id,
|
||||
"to_scenario": current_decision.get("scenario", ""),
|
||||
"type": "influences",
|
||||
"edge_weight": edge_weight,
|
||||
}
|
||||
cause_path = path + [hop]
|
||||
causal_chain.append(cause_path)
|
||||
causal_chain.append(self._build_causal_chain_report(list(reversed(cause_path))))
|
||||
trace_recursive(cause_id, depth + 1, cause_path)
|
||||
|
||||
trace_recursive(decision_id, 0, [])
|
||||
@@ -2942,11 +3082,49 @@ class ContextGraph:
|
||||
self.logger.warning(f"Indirect influence analysis failed: {e}")
|
||||
return set()
|
||||
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> float:
|
||||
def _build_causal_chain_report(self, hops: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Build an auditable causal-chain response from hop records."""
|
||||
hop_count = len(hops)
|
||||
confidence_decay = 1.0
|
||||
weakest_link = None
|
||||
for hop in hops:
|
||||
edge_weight = float(hop.get("edge_weight", 1.0))
|
||||
confidence_decay *= edge_weight
|
||||
if weakest_link is None or edge_weight < float(weakest_link.get("edge_weight", 1.0)):
|
||||
weakest_link = hop
|
||||
|
||||
if hop_count <= 1:
|
||||
interpretation = f"Direct influence with confidence {confidence_decay:.2f}."
|
||||
elif confidence_decay > 0.7:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) with high confidence "
|
||||
f"({confidence_decay:.2f})."
|
||||
)
|
||||
elif confidence_decay > 0.4:
|
||||
interpretation = (
|
||||
f"Mediated through {hop_count - 1} step(s) - confidence decays "
|
||||
f"to {confidence_decay:.2f}."
|
||||
)
|
||||
else:
|
||||
interpretation = (
|
||||
f"Distal influence across {hop_count} causal steps; confidence "
|
||||
f"{confidence_decay:.2f} is weak evidence."
|
||||
)
|
||||
|
||||
return _CausalChain({
|
||||
"hops": hops,
|
||||
"hop_count": hop_count,
|
||||
"confidence_decay": confidence_decay,
|
||||
"weakest_link": weakest_link,
|
||||
"distance_band": classify_path_distance(hop_count),
|
||||
"interpretation": interpretation,
|
||||
})
|
||||
|
||||
def _calculate_decision_influence_score(self, source_id: str, target_id: str) -> Dict[str, float]:
|
||||
"""Calculate influence score between two decisions."""
|
||||
try:
|
||||
if not hasattr(self, '_decisions'):
|
||||
return 0.0
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
|
||||
source_decision = self._decisions[source_id]
|
||||
target_decision = self._decisions[target_id]
|
||||
@@ -2965,11 +3143,16 @@ class ContextGraph:
|
||||
# Combined score
|
||||
combined_score = 0.5 * entity_score + 0.3 * category_score + 0.2 * time_score
|
||||
|
||||
return combined_score
|
||||
return {
|
||||
"score": combined_score,
|
||||
"entity_score": entity_score,
|
||||
"category_score": category_score,
|
||||
"time_score": time_score,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Influence score calculation failed: {e}")
|
||||
return 0.0
|
||||
return {"score": 0.0, "entity_score": 0.0, "category_score": 0.0, "time_score": 0.0}
|
||||
|
||||
def _get_decision_temporal_analysis(self) -> Dict[str, Any]:
|
||||
"""Get temporal analysis of decisions."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import CausalChainResponse, ComplianceResponse, DecisionResponse
|
||||
from ..schemas import CausalChainResponse, CausalDistanceReport, ComplianceResponse, DecisionResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/decisions", tags=["Decisions"])
|
||||
@@ -125,6 +125,20 @@ async def get_precedents(
|
||||
return [_node_to_decision(decision) for _, decision in scored[:limit]]
|
||||
|
||||
|
||||
@router.get("/causal-distance", response_model=CausalDistanceReport)
|
||||
async def causal_distance(
|
||||
source: str = Query(..., description="Source node/decision ID"),
|
||||
target: str = Query(..., description="Target node/decision ID"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-8 — Compute causal distance between two decisions via causal-edge-only traversal."""
|
||||
from ...context.causal_analyzer import CausalChainAnalyzer
|
||||
|
||||
analyzer = CausalChainAnalyzer(session.graph)
|
||||
report = await asyncio.to_thread(analyzer.interpret_causal_distance, source, target)
|
||||
return CausalDistanceReport(**report)
|
||||
|
||||
|
||||
@router.get("/{decision_id}/compliance", response_model=ComplianceResponse)
|
||||
async def check_compliance(
|
||||
decision_id: str,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import ExportRequest, ImportResponse
|
||||
from ..schemas import DistanceExportRequest, ExportRequest, ImportResponse
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -236,3 +236,58 @@ async def export_graph(
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="semantica_export.{extension}"'},
|
||||
)
|
||||
|
||||
|
||||
_DISTANCE_EXPORT_MAX_NODES = 200
|
||||
|
||||
|
||||
@router.post("/api/export/distance-enriched")
|
||||
async def export_distance_enriched(
|
||||
body: DistanceExportRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-10 — Export pairwise distance metrics as CSV or JSONL for ML pipelines."""
|
||||
if not body.node_subset:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"node_subset is required; provide up to {_DISTANCE_EXPORT_MAX_NODES} node IDs to export."
|
||||
),
|
||||
)
|
||||
if len(body.node_subset) > _DISTANCE_EXPORT_MAX_NODES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"node_subset exceeds limit: {len(body.node_subset)} nodes requested; "
|
||||
f"maximum is {_DISTANCE_EXPORT_MAX_NODES}."
|
||||
),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
from ...export.distance_exporter import DistanceExporter
|
||||
|
||||
exporter = DistanceExporter(session.graph)
|
||||
|
||||
if body.format == "csv":
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_csv_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.csv"'},
|
||||
)
|
||||
else:
|
||||
content = await asyncio.to_thread(
|
||||
exporter.to_jsonl_string,
|
||||
include=body.include,
|
||||
node_subset=body.node_subset,
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": 'attachment; filename="distances.jsonl"'},
|
||||
)
|
||||
|
||||
@@ -3,14 +3,20 @@ Graph routes for explorer node, edge, path, and search APIs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ...utils.helpers import classify_path_distance
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import (
|
||||
DistanceMatrixRequest,
|
||||
DistanceMatrixResponse,
|
||||
EdgeListResponse,
|
||||
EdgeResponse,
|
||||
GraphStatsResponse,
|
||||
@@ -21,12 +27,45 @@ from ..schemas import (
|
||||
SearchRequest,
|
||||
SearchResultItem,
|
||||
SearchResultResponse,
|
||||
SemanticNeighborItem,
|
||||
SemanticNeighborhoodResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
router = APIRouter(prefix="/api/graph", tags=["Graph"])
|
||||
|
||||
|
||||
def _build_interpretation(
|
||||
distance_band: str,
|
||||
hop_count: int,
|
||||
bottleneck_node: Optional[str],
|
||||
confidence_decay: Optional[float],
|
||||
) -> str:
|
||||
if distance_band == "direct":
|
||||
base = "Direct relationship"
|
||||
elif distance_band == "near":
|
||||
base = f"Closely related via {hop_count - 1} intermediate node(s)"
|
||||
elif distance_band == "mid-range":
|
||||
base = f"Reachable in {hop_count} steps across topic boundaries"
|
||||
else:
|
||||
base = f"Distal connection spanning {hop_count} hops"
|
||||
|
||||
if bottleneck_node:
|
||||
base += f", routed through bottleneck '{bottleneck_node}'"
|
||||
|
||||
if confidence_decay is not None:
|
||||
if confidence_decay > 0.7:
|
||||
base += " — high confidence."
|
||||
elif confidence_decay > 0.4:
|
||||
base += " — moderate confidence."
|
||||
else:
|
||||
base += " — low confidence, treat as weak evidence."
|
||||
else:
|
||||
base += "."
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float, float]]:
|
||||
if not raw_bbox:
|
||||
return None
|
||||
@@ -175,6 +214,93 @@ async def find_path(
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
distance_band = classify_path_distance(hop_count)
|
||||
|
||||
# FR-4 enrichment — compute optional fields from existing session analytics
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
alternative_path_count: int = 0
|
||||
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
# Build edge weight index once in O(E) so each hop lookup is O(1).
|
||||
# graph_dict may use "edges" or "relationships" depending on the graph source.
|
||||
edge_weight_index: dict = {}
|
||||
for _e in graph_dict.get("edges") or graph_dict.get("relationships", []):
|
||||
_s, _t = _e.get("source"), _e.get("target")
|
||||
_w = float(_e.get("weight", 1.0))
|
||||
edge_weight_index[(_s, _t)] = _w
|
||||
if not directed:
|
||||
edge_weight_index.setdefault((_t, _s), _w)
|
||||
|
||||
# Confidence decay — product of edge weights along the path (O(L))
|
||||
decay = 1.0
|
||||
for i in range(len(path_nodes) - 1):
|
||||
decay *= edge_weight_index.get((path_nodes[i], path_nodes[i + 1]), 1.0)
|
||||
confidence_decay = decay
|
||||
|
||||
# Bottleneck — intermediate node with highest betweenness in subgraph
|
||||
intermediates = path_nodes[1:-1] if len(path_nodes) > 2 else []
|
||||
if intermediates and session.centrality is not None:
|
||||
sub_dict = await asyncio.to_thread(session.build_graph_dict, path_nodes)
|
||||
centrality_result = await asyncio.to_thread(
|
||||
session.centrality.calculate_betweenness_centrality, sub_dict
|
||||
)
|
||||
scores = centrality_result.get("betweenness", {}) if isinstance(centrality_result, dict) else {}
|
||||
if scores:
|
||||
bottleneck_node = max(
|
||||
(n for n in intermediates if n in scores),
|
||||
key=lambda n: scores.get(n, 0.0),
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Alternative paths — count simple paths within hop_count + 2
|
||||
if path_finder is not None and hop_count > 0:
|
||||
try:
|
||||
k_paths = await asyncio.to_thread(
|
||||
path_finder.find_k_shortest_paths,
|
||||
graph_dict, node_id, target, hop_count + 2, directed=directed
|
||||
)
|
||||
alternative_path_count = max(0, len(k_paths) - 1)
|
||||
except Exception as exc:
|
||||
logger.debug("k_shortest_paths unavailable for enrichment: %s", exc)
|
||||
|
||||
# Semantic similarity (source ↔ target)
|
||||
if session.similarity is not None:
|
||||
try:
|
||||
sim_result = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, node_id, target
|
||||
)
|
||||
if isinstance(sim_result, (int, float)):
|
||||
semantic_similarity = float(sim_result)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_similarity unavailable for enrichment: %s", exc)
|
||||
|
||||
# Path coherence — mean pairwise similarity of consecutive nodes
|
||||
if session.similarity is not None and len(path_nodes) >= 2:
|
||||
try:
|
||||
pair_sims: List[float] = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, path_nodes[i], path_nodes[i + 1]
|
||||
)
|
||||
if isinstance(sim, (int, float)):
|
||||
pair_sims.append(float(sim))
|
||||
if pair_sims:
|
||||
path_coherence_score = sum(pair_sims) / len(pair_sims)
|
||||
except Exception as exc:
|
||||
logger.debug("path_coherence unavailable for enrichment: %s", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("FR-4 enrichment skipped: %s", exc)
|
||||
|
||||
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
|
||||
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
target=target,
|
||||
@@ -184,7 +310,13 @@ async def find_path(
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
distance_band=distance_band,
|
||||
semantic_similarity=semantic_similarity,
|
||||
path_coherence_score=path_coherence_score,
|
||||
confidence_decay=confidence_decay,
|
||||
bottleneck_node=bottleneck_node,
|
||||
alternative_path_count=alternative_path_count,
|
||||
interpretation=interpretation,
|
||||
)
|
||||
|
||||
|
||||
@@ -194,13 +326,178 @@ async def search_nodes(
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
||||
items = [
|
||||
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
|
||||
for result in results
|
||||
]
|
||||
|
||||
# FR-7 — compute hop distances from anchor when requested
|
||||
hop_by_id: dict = {}
|
||||
if body.anchor_node:
|
||||
neighbors = await asyncio.to_thread(
|
||||
session.graph.get_neighbor_distances,
|
||||
body.anchor_node,
|
||||
hops=body.max_hops if body.max_hops is not None else 10,
|
||||
)
|
||||
hop_by_id = {n.get("id"): n.get("hop") for n in neighbors}
|
||||
hop_by_id[body.anchor_node] = 0
|
||||
|
||||
items: List[SearchResultItem] = []
|
||||
for result in results:
|
||||
node_data = result.get("node", {})
|
||||
node_id = node_data.get("id", "")
|
||||
raw_score = result.get("score", 0.0)
|
||||
|
||||
hop_distance: Optional[int] = hop_by_id.get(node_id) if body.anchor_node else None
|
||||
|
||||
# Drop results beyond max_hops
|
||||
if body.anchor_node and body.max_hops is not None:
|
||||
if hop_distance is None or hop_distance > body.max_hops:
|
||||
continue
|
||||
|
||||
# Compute combined ranking score
|
||||
final_score = raw_score
|
||||
if body.anchor_node and hop_distance is not None:
|
||||
proximity = 1.0 if hop_distance == 0 else 1.0 / hop_distance
|
||||
if body.rank_by == "proximity":
|
||||
final_score = proximity
|
||||
elif body.rank_by == "hybrid":
|
||||
final_score = 0.6 * raw_score + 0.4 * proximity
|
||||
|
||||
items.append(
|
||||
SearchResultItem(
|
||||
node=_node_response(node_data),
|
||||
score=final_score,
|
||||
hop_distance=hop_distance,
|
||||
)
|
||||
)
|
||||
|
||||
if body.rank_by in ("proximity", "hybrid") and body.anchor_node:
|
||||
items.sort(key=lambda item: item.score, reverse=True)
|
||||
|
||||
return SearchResultResponse(results=items, total=len(items), query=body.query)
|
||||
|
||||
|
||||
@router.post("/distance-matrix", response_model=DistanceMatrixResponse)
|
||||
async def distance_matrix(
|
||||
body: DistanceMatrixRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if len(body.node_ids) > 50:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Too many nodes: {len(body.node_ids)} requested; maximum is 50 per request.",
|
||||
)
|
||||
|
||||
if body.metric == "semantic" and session.similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="metric='semantic' requires an embedding backend which is not available in this session.",
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_finder = session.path_finder
|
||||
|
||||
n = len(body.node_ids)
|
||||
matrix: List[List[Optional[float]]] = [[None] * n for _ in range(n)]
|
||||
unreachable: List[tuple] = []
|
||||
|
||||
for i in range(n):
|
||||
matrix[i][i] = 0.0
|
||||
for j in range(i + 1, n):
|
||||
src, tgt = body.node_ids[i], body.node_ids[j]
|
||||
try:
|
||||
if body.metric == "semantic" and session.similarity is not None:
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity, graph_dict, src, tgt
|
||||
)
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
path_fn = (
|
||||
path_finder.dijkstra_shortest_path
|
||||
if body.metric == "weighted"
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, src, tgt)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if path_nodes:
|
||||
val = (
|
||||
float(result.get("total_weight", len(path_nodes) - 1))
|
||||
if body.metric == "weighted"
|
||||
else float(len(path_nodes) - 1)
|
||||
)
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
else:
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
except Exception as exc:
|
||||
logger.debug("distance_matrix pair (%s, %s) failed: %s", src, tgt, exc)
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
return DistanceMatrixResponse(
|
||||
nodes=body.node_ids,
|
||||
metric=body.metric,
|
||||
matrix=matrix,
|
||||
unreachable_pairs=unreachable,
|
||||
computation_time_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood(
|
||||
node_id: str,
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
neighbors: List[SemanticNeighborItem] = []
|
||||
if session.similarity is not None:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
try:
|
||||
similar = await asyncio.to_thread(
|
||||
session.similarity.find_most_similar,
|
||||
graph_dict, node_id, top_k=top_k * 2
|
||||
)
|
||||
# find_most_similar returns list of (node_id, score) or dicts
|
||||
for item in similar:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
nid, sim_score = item[0], item[1]
|
||||
elif isinstance(item, dict):
|
||||
nid = item.get("node_id") or item.get("id", "")
|
||||
sim_score = item.get("similarity", item.get("score", 0.0))
|
||||
else:
|
||||
continue
|
||||
if float(sim_score) < min_similarity or nid == node_id:
|
||||
continue
|
||||
neighbor_node = await asyncio.to_thread(session.get_node, nid)
|
||||
if neighbor_node is None:
|
||||
continue
|
||||
neighbors.append(
|
||||
SemanticNeighborItem(
|
||||
id=nid,
|
||||
type=neighbor_node.get("type", ""),
|
||||
content=neighbor_node.get("content", ""),
|
||||
similarity=float(sim_score),
|
||||
)
|
||||
)
|
||||
if len(neighbors) >= top_k:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
|
||||
|
||||
return SemanticNeighborhoodResponse(
|
||||
anchor_node=node_id,
|
||||
neighbors=neighbors,
|
||||
total=len(neighbors),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GraphStatsResponse)
|
||||
async def graph_stats(
|
||||
session: GraphSession = Depends(get_session),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -5,14 +5,20 @@ Temporal routes for snapshots, diffs, and pattern detection.
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone, UTC
|
||||
from datetime import datetime, timedelta, timezone, UTC
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..dependencies import get_session
|
||||
from ..schemas import TemporalDiffResponse, TemporalPatternResponse
|
||||
from ..schemas import (
|
||||
DistanceEvent,
|
||||
DistanceHistoryResponse,
|
||||
DistanceSnapshot,
|
||||
TemporalDiffResponse,
|
||||
TemporalPatternResponse,
|
||||
)
|
||||
from ..session import GraphSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,3 +126,122 @@ async def temporal_bounds(
|
||||
):
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
return TemporalBoundsResponse(**bounds)
|
||||
|
||||
|
||||
@router.get("/distance-history", response_model=DistanceHistoryResponse)
|
||||
async def distance_history(
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
metric: str = Query("hops", description="Distance metric: hops | weighted"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""FR-9 — Track distance changes between two nodes across temporal snapshots."""
|
||||
from ...utils.helpers import classify_path_distance
|
||||
|
||||
bounds = await asyncio.to_thread(session.get_temporal_bounds)
|
||||
min_bound_str = bounds.get("min")
|
||||
max_bound_str = bounds.get("max")
|
||||
|
||||
if not min_bound_str or not max_bound_str:
|
||||
# No temporal data — return current-only snapshot
|
||||
pf = session.path_finder
|
||||
hop_count: Optional[int] = None
|
||||
if pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r metric=%r: %s",
|
||||
source, target, metric, exc, exc_info=True,
|
||||
)
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
snap = DistanceSnapshot(
|
||||
timestamp=now,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count) if hop_count is not None else "distant",
|
||||
)
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=[snap], events=[],
|
||||
)
|
||||
|
||||
min_bound = _parse_query_dt(min_bound_str)
|
||||
max_bound = _parse_query_dt(max_bound_str)
|
||||
|
||||
# Sample up to 10 snapshots evenly between min and max
|
||||
total_seconds = max(1, int((max_bound - min_bound).total_seconds()))
|
||||
step = total_seconds / min(10, total_seconds)
|
||||
sample_times = [
|
||||
min_bound + timedelta(seconds=int(i * step))
|
||||
for i in range(11)
|
||||
]
|
||||
|
||||
pf = session.path_finder
|
||||
history: List[DistanceSnapshot] = []
|
||||
events: List[DistanceEvent] = []
|
||||
prev_hop: Optional[int] = None
|
||||
|
||||
for sample_time in sample_times:
|
||||
active_nodes = await asyncio.to_thread(session.get_active_nodes, at_time=sample_time)
|
||||
active_ids = {n.get("id") for n in active_nodes if n.get("id")}
|
||||
hop_count = None
|
||||
if source in active_ids and target in active_ids and pf is not None:
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(
|
||||
session.build_graph_dict, list(active_ids)
|
||||
)
|
||||
path_fn = pf.dijkstra_shortest_path if metric == "weighted" else pf.bfs_shortest_path
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else None
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"distance_history path computation failed for source=%r target=%r at=%s metric=%s: %s",
|
||||
source, target, sample_time.isoformat(), metric, exc, exc_info=True,
|
||||
)
|
||||
hop_count = None
|
||||
|
||||
band = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
snap = DistanceSnapshot(timestamp=sample_time, hop_count=hop_count, distance_band=band)
|
||||
history.append(snap)
|
||||
|
||||
# Detect events relative to previous snapshot
|
||||
if prev_hop is not None or hop_count is not None:
|
||||
if prev_hop is None and hop_count is not None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="reconnected",
|
||||
hop_count_before=None,
|
||||
hop_count_after=hop_count,
|
||||
description=f"Nodes reconnected at {hop_count} hop(s) on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is None:
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type="disconnected",
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=None,
|
||||
description=f"Nodes became unreachable on {sample_time.date()}.",
|
||||
))
|
||||
elif prev_hop is not None and hop_count is not None and hop_count != prev_hop:
|
||||
etype = "convergence" if hop_count < prev_hop else "divergence"
|
||||
events.append(DistanceEvent(
|
||||
timestamp=sample_time,
|
||||
event_type=etype,
|
||||
hop_count_before=prev_hop,
|
||||
hop_count_after=hop_count,
|
||||
description=(
|
||||
f"Nodes {etype}d from {prev_hop} hops to {hop_count} hops "
|
||||
f"on {sample_time.date()}."
|
||||
),
|
||||
))
|
||||
prev_hop = hop_count
|
||||
|
||||
return DistanceHistoryResponse(
|
||||
source_id=source, target_id=target, metric=metric,
|
||||
history=history, events=events,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Shared Pydantic schemas for the Semantica Knowledge Explorer API.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -70,6 +71,13 @@ class PathResponse(BaseModel):
|
||||
directed: bool = True
|
||||
hop_count: int = 0
|
||||
distance_band: str = "direct"
|
||||
# FR-4 enrichment fields — all optional; existing callers unaffected
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
alternative_path_count: int = 0
|
||||
interpretation: str = ""
|
||||
|
||||
|
||||
class GraphStatsResponse(BaseModel):
|
||||
@@ -84,11 +92,19 @@ class SearchRequest(BaseModel):
|
||||
query: str
|
||||
filters: Dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=20, ge=1, le=200)
|
||||
# FR-7 proximity constraint fields
|
||||
anchor_node: Optional[str] = None
|
||||
max_hops: Optional[int] = None
|
||||
min_semantic_similarity: Optional[float] = None
|
||||
rank_by: Literal["relevance", "proximity", "hybrid"] = "relevance"
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
node: NodeResponse
|
||||
score: float = 0.0
|
||||
# FR-7 distance metadata
|
||||
hop_distance: Optional[int] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
|
||||
|
||||
class SearchResultResponse(BaseModel):
|
||||
@@ -288,3 +304,111 @@ class MergeResponse(BaseModel):
|
||||
merged_into: str
|
||||
removed_ids: List[str]
|
||||
edges_updated: int
|
||||
|
||||
|
||||
class ProvenanceNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
prov_type: str
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class ProvenanceEdge(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
label: str
|
||||
direction: str
|
||||
|
||||
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-6 — Distance Matrix API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceMatrixRequest(BaseModel):
|
||||
node_ids: List[str]
|
||||
metric: Literal["hops", "weighted", "semantic"] = "hops"
|
||||
|
||||
|
||||
class DistanceMatrixResponse(BaseModel):
|
||||
nodes: List[str]
|
||||
metric: str
|
||||
matrix: List[List[Optional[float]]]
|
||||
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
|
||||
computation_time_ms: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-3 backend — Semantic Neighborhood
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SemanticNeighborItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
similarity: float
|
||||
hop_distance: Optional[int] = None
|
||||
|
||||
|
||||
class SemanticNeighborhoodResponse(BaseModel):
|
||||
anchor_node: str
|
||||
neighbors: List[SemanticNeighborItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-8 — Causal Distance Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CausalDistanceReport(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
causal_path: List[str]
|
||||
causal_hop_count: int
|
||||
intermediate_decisions: List[str]
|
||||
confidence_decay: float
|
||||
weakest_link: Optional[Dict[str, Any]] = None
|
||||
interpretation: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-9 — Temporal Distance Alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceSnapshot(BaseModel):
|
||||
timestamp: datetime
|
||||
hop_count: Optional[int] = None
|
||||
distance_band: str
|
||||
|
||||
|
||||
class DistanceEvent(BaseModel):
|
||||
timestamp: datetime
|
||||
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
|
||||
hop_count_before: Optional[int] = None
|
||||
hop_count_after: Optional[int] = None
|
||||
description: str
|
||||
|
||||
|
||||
class DistanceHistoryResponse(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
metric: str
|
||||
history: List[DistanceSnapshot]
|
||||
events: List[DistanceEvent]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-10 — Distance-Enriched Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceExportRequest(BaseModel):
|
||||
format: Literal["csv", "jsonl"] = "csv"
|
||||
node_subset: Optional[List[str]] = None
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,7 @@ License: MIT
|
||||
"""
|
||||
|
||||
from .arango_aql_exporter import ArangoAQLExporter
|
||||
from .distance_exporter import DistanceExporter
|
||||
from .config import ExportConfig, export_config
|
||||
|
||||
try:
|
||||
@@ -220,6 +221,7 @@ __all__ = [
|
||||
# Core Exporters
|
||||
"ArrowExporter",
|
||||
"ArangoAQLExporter",
|
||||
"DistanceExporter",
|
||||
"RDFExporter",
|
||||
"RDFSerializer",
|
||||
"RDFValidator",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Distance-Enriched Export (FR-10)
|
||||
|
||||
Exports pairwise node distance metrics — hop count, weighted distance,
|
||||
semantic similarity, distance band, betweenness centrality — in CSV or
|
||||
JSONL format for downstream ML pipelines (GNN training, clustering,
|
||||
link prediction).
|
||||
|
||||
Python API:
|
||||
exporter = DistanceExporter(graph)
|
||||
df = exporter.to_dataframe(include=["hops", "semantic_similarity", "distance_band"])
|
||||
exporter.to_csv("distances.csv")
|
||||
exporter.to_jsonl("distances.jsonl")
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.helpers import classify_path_distance
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_KG_AVAILABLE = False
|
||||
try:
|
||||
from ..kg import PathFinder, SimilarityCalculator, CentralityCalculator
|
||||
_KG_AVAILABLE = True
|
||||
except ImportError as exc:
|
||||
logger.debug("KG components not available; distance exporter will run in reduced mode: %s", exc)
|
||||
|
||||
_ALL_COLUMNS = [
|
||||
"source_id", "source_type", "target_id", "target_type",
|
||||
"hop_count", "weighted_distance", "semantic_similarity",
|
||||
"distance_band", "source_betweenness", "target_betweenness",
|
||||
]
|
||||
|
||||
|
||||
class DistanceExporter:
|
||||
"""Compute and export pairwise distance metrics for a ContextGraph."""
|
||||
|
||||
def __init__(self, graph: Any) -> None:
|
||||
self.graph = graph
|
||||
self._path_finder = PathFinder() if _KG_AVAILABLE else None
|
||||
self._similarity = SimilarityCalculator() if _KG_AVAILABLE else None
|
||||
self._centrality = CentralityCalculator() if _KG_AVAILABLE else None
|
||||
|
||||
def _build_graph_dict(self) -> Dict[str, Any]:
|
||||
nodes = [
|
||||
{"id": n.node_id, "type": n.node_type, "content": n.content, "properties": n.properties}
|
||||
for n in self.graph.nodes.values()
|
||||
]
|
||||
edges_raw = getattr(self.graph, "edges", [])
|
||||
edges = [
|
||||
{
|
||||
"id": e.edge_id, "source": e.source_id, "target": e.target_id,
|
||||
"type": e.edge_type, "weight": e.weight,
|
||||
}
|
||||
for e in edges_raw
|
||||
]
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
def _node_type(self, node_id: str) -> str:
|
||||
node = getattr(self.graph, "nodes", {}).get(node_id)
|
||||
return getattr(node, "node_type", "") if node else ""
|
||||
|
||||
def _betweenness(self, graph_dict: Dict[str, Any]) -> Dict[str, float]:
|
||||
if self._centrality is None:
|
||||
return {}
|
||||
try:
|
||||
result = self._centrality.calculate_betweenness_centrality(graph_dict)
|
||||
return result.get("betweenness", {}) if isinstance(result, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _hop_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[int]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.bfs_shortest_path(graph_dict, src, tgt)
|
||||
path = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
return len(path) - 1 if path else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _weighted_distance(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._path_finder is None:
|
||||
return None
|
||||
try:
|
||||
result = self._path_finder.dijkstra_shortest_path(graph_dict, src, tgt)
|
||||
if isinstance(result, dict):
|
||||
return float(result.get("total_weight", len(result.get("path", [])) - 1))
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _semantic_similarity(self, graph_dict: Dict[str, Any], src: str, tgt: str) -> Optional[float]:
|
||||
if self._similarity is None:
|
||||
return None
|
||||
try:
|
||||
sim = self._similarity.cosine_similarity(graph_dict, src, tgt)
|
||||
return float(sim) if isinstance(sim, (int, float)) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def compute_pairs(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compute all pairwise distance metrics and return as a list of dicts."""
|
||||
include_set = set(include or _ALL_COLUMNS)
|
||||
graph_dict = self._build_graph_dict()
|
||||
|
||||
node_ids = node_subset or list(self.graph.nodes.keys())
|
||||
|
||||
betweenness: Dict[str, float] = {}
|
||||
if "source_betweenness" in include_set or "target_betweenness" in include_set:
|
||||
betweenness = self._betweenness(graph_dict)
|
||||
|
||||
rows = []
|
||||
for i, src in enumerate(node_ids):
|
||||
for tgt in node_ids:
|
||||
if src == tgt:
|
||||
continue
|
||||
row: Dict[str, Any] = {}
|
||||
if "source_id" in include_set:
|
||||
row["source_id"] = src
|
||||
if "source_type" in include_set:
|
||||
row["source_type"] = self._node_type(src)
|
||||
if "target_id" in include_set:
|
||||
row["target_id"] = tgt
|
||||
if "target_type" in include_set:
|
||||
row["target_type"] = self._node_type(tgt)
|
||||
|
||||
hop_count: Optional[int] = None
|
||||
if "hop_count" in include_set or "distance_band" in include_set:
|
||||
hop_count = self._hop_distance(graph_dict, src, tgt)
|
||||
if "hop_count" in include_set:
|
||||
row["hop_count"] = hop_count
|
||||
|
||||
if "weighted_distance" in include_set:
|
||||
row["weighted_distance"] = self._weighted_distance(graph_dict, src, tgt)
|
||||
|
||||
if "semantic_similarity" in include_set:
|
||||
row["semantic_similarity"] = self._semantic_similarity(graph_dict, src, tgt)
|
||||
|
||||
if "distance_band" in include_set:
|
||||
row["distance_band"] = classify_path_distance(hop_count) if hop_count is not None else "distant"
|
||||
|
||||
if "source_betweenness" in include_set:
|
||||
row["source_betweenness"] = betweenness.get(src)
|
||||
if "target_betweenness" in include_set:
|
||||
row["target_betweenness"] = betweenness.get(tgt)
|
||||
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
def to_dataframe(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> Any:
|
||||
"""Return a pandas DataFrame of pairwise distances."""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise ImportError("pandas is required for to_dataframe()") from exc
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def to_csv(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a CSV file."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
fh.write("")
|
||||
return
|
||||
fieldnames = list(rows[0].keys())
|
||||
with open(path, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
def to_jsonl(
|
||||
self,
|
||||
path: str,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Write pairwise distances to a JSONL file (one JSON object per line)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, default=str) + "\n")
|
||||
|
||||
def to_csv_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return CSV as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
if not rows:
|
||||
return ""
|
||||
buf = io.StringIO()
|
||||
fieldnames = list(rows[0].keys())
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buf.getvalue()
|
||||
|
||||
def to_jsonl_string(
|
||||
self,
|
||||
include: Optional[List[str]] = None,
|
||||
node_subset: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""Return JSONL as a string (for API responses)."""
|
||||
rows = self.compute_pairs(include=include, node_subset=node_subset)
|
||||
return "\n".join(json.dumps(row, default=str) for row in rows)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Targeted regression tests for all 13 Qodo review fixes on the Distance Intelligence PR."""
|
||||
import re
|
||||
import inspect
|
||||
|
||||
|
||||
# ── bug_003: include_distance_metadata=False is the backward-compat default ───
|
||||
|
||||
def test_bug003_metadata_absent_by_default():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A")
|
||||
assert len(neighbors) == 1
|
||||
assert "hop" in neighbors[0]
|
||||
assert "distance_band" not in neighbors[0], (
|
||||
f"distance_band should be absent by default; got keys: {list(neighbors[0].keys())}"
|
||||
)
|
||||
assert "confidence_decay" not in neighbors[0]
|
||||
assert "path_to_anchor" not in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_metadata_present_with_flag():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A", include_distance_metadata=True)
|
||||
assert len(neighbors) == 1
|
||||
assert "distance_band" in neighbors[0]
|
||||
assert "confidence_decay" in neighbors[0]
|
||||
assert "path_to_anchor" in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_get_neighbor_distances_still_works():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related", weight=0.9)
|
||||
nd = g.get_neighbor_distances("A")
|
||||
assert len(nd) == 1
|
||||
assert nd[0]["distance_band"] == "direct"
|
||||
assert abs(nd[0]["confidence_decay"] - 0.9) < 1e-9
|
||||
assert "path_to_anchor" in nd[0]
|
||||
|
||||
|
||||
# ── bug_004: weakest_link standardized to edge_weight key ─────────────────────
|
||||
|
||||
def test_bug004_weakest_link_uses_edge_weight_key():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "decision")
|
||||
g.add_node("B", "decision")
|
||||
g.add_node("C", "decision")
|
||||
g.add_edge("A", "B", "causes", weight=0.8)
|
||||
g.add_edge("B", "C", "causes", weight=0.5)
|
||||
analyzer = CausalChainAnalyzer(g)
|
||||
report = analyzer.interpret_causal_distance("A", "C")
|
||||
wl = report.get("weakest_link")
|
||||
assert wl is not None, "weakest_link must be set for a 2-hop causal path"
|
||||
assert "edge_weight" in wl, f"Expected edge_weight key, got: {list(wl.keys())}"
|
||||
assert "weight" not in wl, f"Old key 'weight' should be absent; got: {list(wl.keys())}"
|
||||
assert wl["edge_weight"] == 0.5
|
||||
|
||||
|
||||
def test_bug004_causal_distance_report_schema_validates():
|
||||
from semantica.explorer.schemas import CausalDistanceReport
|
||||
report = CausalDistanceReport(
|
||||
source_id="A",
|
||||
target_id="C",
|
||||
causal_path=["A", "B", "C"],
|
||||
causal_hop_count=2,
|
||||
intermediate_decisions=["B"],
|
||||
confidence_decay=0.4,
|
||||
weakest_link={"source": "A", "target": "B", "edge_weight": 0.5},
|
||||
interpretation="Test path",
|
||||
)
|
||||
assert report.weakest_link["edge_weight"] == 0.5
|
||||
|
||||
|
||||
# ── qual_003: _distance_band static methods removed; classify_path_distance used ─
|
||||
|
||||
def test_qual003_distance_band_removed_from_causal_analyzer():
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
assert not hasattr(CausalChainAnalyzer, "_distance_band")
|
||||
ca_src = inspect.getsource(CausalChainAnalyzer)
|
||||
assert "def _distance_band" not in ca_src
|
||||
assert "classify_path_distance" in ca_src
|
||||
|
||||
|
||||
def test_qual003_distance_band_removed_from_agent_context():
|
||||
import semantica.context.agent_context as ac_mod
|
||||
ac_src = inspect.getsource(ac_mod)
|
||||
assert "def _distance_band" not in ac_src
|
||||
assert "classify_path_distance" in ac_src
|
||||
|
||||
|
||||
# ── bug_005: timedelta arithmetic — no timetuple reconstruction ───────────────
|
||||
|
||||
def test_bug005_no_timetuple_hack_in_distance_history():
|
||||
from semantica.explorer.routes import temporal
|
||||
src = inspect.getsource(temporal.distance_history)
|
||||
assert "timetuple" not in src, "Old timetuple hack should be gone"
|
||||
assert "__import__" not in src, "Dynamic import hack should be gone"
|
||||
assert "timedelta(seconds" in src
|
||||
|
||||
|
||||
# ── sec_001: node_subset capped at 200 ────────────────────────────────────────
|
||||
|
||||
def test_sec001_node_subset_limit_constant_exists():
|
||||
from semantica.explorer.routes.export_import import _DISTANCE_EXPORT_MAX_NODES
|
||||
assert _DISTANCE_EXPORT_MAX_NODES == 200
|
||||
|
||||
|
||||
def test_sec001_export_endpoint_validates_subset_size():
|
||||
from semantica.explorer.routes import export_import
|
||||
src = inspect.getsource(export_import.export_distance_enriched)
|
||||
assert "_DISTANCE_EXPORT_MAX_NODES" in src
|
||||
assert "status_code=413" in src
|
||||
|
||||
|
||||
# ── sec_002: distance matrix upper-triangle only ──────────────────────────────
|
||||
|
||||
def test_sec002_distance_matrix_upper_triangle_loop():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.distance_matrix)
|
||||
assert "range(i + 1, n)" in src, "Should use upper-triangle loop"
|
||||
assert "matrix[j][i]" in src, "Should mirror lower triangle"
|
||||
|
||||
|
||||
# ── bug_006: O(L) edge weight index built once ────────────────────────────────
|
||||
|
||||
def test_bug006_edge_weight_index_built_once():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.find_path)
|
||||
assert "edge_weight_index" in src
|
||||
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
|
||||
|
||||
|
||||
# ── bug_007: original result id not overwritten ───────────────────────────────
|
||||
|
||||
def test_bug007_original_id_not_overwritten():
|
||||
from semantica.context import agent_context
|
||||
src = inspect.getsource(agent_context.AgentContext._apply_proximity_metadata)
|
||||
assert (
|
||||
'"graph_node_id": result_id' in src
|
||||
or "'graph_node_id': result_id" in src
|
||||
)
|
||||
assert '"id": result_id' not in src, "id should not be overwritten by result_id"
|
||||
|
||||
|
||||
# ── qual_002: no bare except:pass in enrichment blocks ───────────────────────
|
||||
|
||||
def test_qual002_no_bare_except_pass_in_find_path():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.find_path)
|
||||
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
|
||||
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
|
||||
assert "logger.debug" in src
|
||||
|
||||
|
||||
# ── TypeScript fixes — checked via raw file reads ─────────────────────────────
|
||||
|
||||
TS_BEHAVIOR = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\behaviors\pathHighlightBehavior.ts"
|
||||
)
|
||||
TS_WORKSPACE = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\GraphWorkspace.tsx"
|
||||
)
|
||||
|
||||
|
||||
def test_bug008_sweep_generation_counter():
|
||||
with open(TS_BEHAVIOR, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "sweepGeneration" in src, "Generation counter variable must exist"
|
||||
assert "gen !== sweepGeneration" in src, "Stale-callback guard must exist"
|
||||
assert "sweepGeneration++" in src, "Counter must be incremented on cancel"
|
||||
|
||||
|
||||
def test_bug001_semantic_neighborhood_uses_top_k():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "top_k=50" in src, "Should use top_k (not limit) to match backend param"
|
||||
idx = src.find("semantic-neighborhood?")
|
||||
snippet = src[idx: idx + 100]
|
||||
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
|
||||
|
||||
|
||||
def test_bug002_semantic_neighborhood_response_type_complete():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "anchor_node: string" in src
|
||||
assert "hop_distance?" in src
|
||||
|
||||
|
||||
def test_qual001_ego_heatmap_merged_into_single_effect():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId" in src, (
|
||||
"Combined dep array must be present"
|
||||
)
|
||||
# The old separate dep arrays must not exist
|
||||
assert "], [egoModeEnabled, egoMaxHops, selectedNodeId]" not in src
|
||||
assert "], [heatmapEnabled, selectedNodeId]" not in src
|
||||
@@ -0,0 +1,97 @@
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
|
||||
def test_get_neighbor_distances_tracks_path_decay_and_band():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("A", "entity", "Anchor")
|
||||
graph.add_node("B", "entity", "Bridge")
|
||||
graph.add_node("C", "decision", "Decision")
|
||||
graph.add_edge("A", "B", "influences", weight=0.9)
|
||||
graph.add_edge("B", "C", "influences", weight=0.7)
|
||||
|
||||
neighbors = graph.get_neighbor_distances("A", hops=2, min_confidence=0.5)
|
||||
c_neighbor = next(item for item in neighbors if item["id"] == "C")
|
||||
|
||||
assert c_neighbor["hop"] == 2
|
||||
assert c_neighbor["distance_band"] == "near"
|
||||
assert c_neighbor["confidence_decay"] == 0.63
|
||||
assert c_neighbor["path_to_anchor"] == ["A", "B", "C"]
|
||||
|
||||
|
||||
def test_trace_decision_causality_returns_auditable_chain_dicts():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
first = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve initial risk policy",
|
||||
reasoning="Baseline risk controls look sound",
|
||||
outcome="approved",
|
||||
confidence=0.8,
|
||||
entities=["account_123"],
|
||||
)
|
||||
second = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve follow-up risk exception",
|
||||
reasoning="Prior account controls still apply",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["account_123"],
|
||||
)
|
||||
graph._decisions[first]["timestamp"] = 1
|
||||
graph._decisions[second]["timestamp"] = 2
|
||||
|
||||
chains = graph.trace_decision_causality(second, max_depth=2)
|
||||
|
||||
assert chains
|
||||
assert chains[0]["hop_count"] == 1
|
||||
assert chains[0]["distance_band"] == "direct"
|
||||
assert chains[0]["weakest_link"]["from"] == first
|
||||
assert chains[0]["hops"][0]["to"] == second
|
||||
assert "confidence" in chains[0]["interpretation"]
|
||||
assert list(chains[0])[0]["from"] == first
|
||||
|
||||
|
||||
def test_analyze_decision_influence_exposes_score_breakdown():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
source = graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Approve secured loan",
|
||||
reasoning="Collateral and income verified",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Review related refinance",
|
||||
reasoning="Same borrower and collateral",
|
||||
outcome="review",
|
||||
confidence=0.8,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
|
||||
result = graph.analyze_decision_influence(source)
|
||||
|
||||
assert result["influence_scores"]
|
||||
score = result["influence_scores"][0]
|
||||
assert set(score["score_breakdown"]) == {
|
||||
"entity_overlap",
|
||||
"category_match",
|
||||
"temporal_proximity",
|
||||
}
|
||||
assert score["is_direct"] is True
|
||||
|
||||
|
||||
def test_cross_graph_path_traverses_link_boundary():
|
||||
left = ContextGraph(advanced_analytics=False)
|
||||
right = ContextGraph(advanced_analytics=False)
|
||||
left.add_node("A", "entity", "Left")
|
||||
right.add_node("B", "entity", "Right")
|
||||
left.link_graph(right, "A", "B")
|
||||
|
||||
path = left.cross_graph_path("A", right, "B")
|
||||
|
||||
assert path["reachable"] is True
|
||||
assert path["hop_count"] == 1
|
||||
assert path["cross_graph_links_used"] == 1
|
||||
assert path["distance_band"] == "direct"
|
||||
assert path["path"] == [(left.graph_id, "A"), (right.graph_id, "B")]
|
||||
@@ -36,6 +36,16 @@ def _build_sample_graph() -> ContextGraph:
|
||||
graph.add_node("javascript", node_type="language", content="JavaScript programming language", x=100, y=120)
|
||||
graph.add_node("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",
|
||||
@@ -244,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
|
||||
|
||||
@@ -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
|
||||
@@ -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