mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3a8c9895 | ||
|
|
508e05d367 | ||
|
|
03f99f016d | ||
|
|
56e7d9d821 | ||
|
|
6860bdbec3 | ||
|
|
ac5015bc3f | ||
|
|
29c72f59b3 | ||
|
|
21c2f190f8 | ||
|
|
8ef67b8bda | ||
|
|
bc57837b86 | ||
|
|
0439cf884d | ||
|
|
141bf80394 | ||
|
|
0c4e18e256 | ||
|
|
39045d783b | ||
|
|
afa54e1bbf | ||
|
|
a01b3c36fc | ||
|
|
0dda380580 | ||
|
|
e7c9f6e7f3 | ||
|
|
b828ebfef0 | ||
|
|
6330627ddb | ||
|
|
24e327d161 | ||
|
|
b9f3c59443 | ||
|
|
34955ba246 | ||
|
|
95b46f12b6 | ||
|
|
245ff76b99 | ||
|
|
a2b6a481dc | ||
|
|
c7edba88ea | ||
|
|
eb8598e0cf | ||
|
|
64d4157644 | ||
|
|
e1b1e63541 | ||
|
|
6b0a8e60ce | ||
|
|
8b22a58b8f | ||
|
|
ba286a55ea | ||
|
|
f610eff0ed | ||
|
|
e9e7278720 | ||
|
|
6b2cafd02e | ||
|
|
90d857a98f | ||
|
|
1861ca578c | ||
|
|
63acc7a66e | ||
|
|
00ceb09960 | ||
|
|
2909e0fa14 | ||
|
|
d2574990fa | ||
|
|
53d1da87c0 | ||
|
|
9e916a82b5 | ||
|
|
e8bf0e50d3 | ||
|
|
269fdaa9fb | ||
|
|
2d9bbf08b1 | ||
|
|
fbbe36983b | ||
|
|
877903358a | ||
|
|
070b36902b | ||
|
|
3b9efb7856 | ||
|
|
2a031f0225 | ||
|
|
d04f2b3643 | ||
|
|
2811469071 | ||
|
|
a0b4793590 | ||
|
|
cdeb04b816 | ||
|
|
b7e31d82b0 | ||
|
|
2eba54caac | ||
|
|
9fd1df9c51 | ||
|
|
4517089a7f | ||
|
|
9553e1a176 | ||
|
|
04fcfb61a7 | ||
|
|
5dc6966706 | ||
|
|
bb956b2735 | ||
|
|
e3a3f6010b | ||
|
|
b6373204e2 | ||
|
|
e385c78977 | ||
|
|
7b581d5960 | ||
|
|
07ca93fe5c | ||
|
|
41e430b928 | ||
|
|
438d8bc7af | ||
|
|
82f1f6bd10 | ||
|
|
c86570b996 | ||
|
|
93dda5e435 | ||
|
|
6ffed78fd9 | ||
|
|
f06de0dab2 | ||
|
|
dd016744ce | ||
|
|
379994867d | ||
|
|
7884d71e23 |
+427
-2138
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
|
||||
**A Framework for Building Context Graphs and Decision Intelligence Layers for AI**
|
||||
|
||||
🌐 **[Website](https://getsemantica.ai/)** | 📚 **[Documentation](https://docs.getsemantica.ai/)**
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pypi.org/project/semantica/)
|
||||
@@ -1029,6 +1031,27 @@ pytest tests/
|
||||
|
||||
---
|
||||
|
||||
## 🏢 Enterprise Support
|
||||
|
||||
**Production-grade Semantica for your organization**
|
||||
|
||||
🚀 **[Website](https://getsemantica.ai/)** — Enterprise solutions and custom domain-specific implementations
|
||||
|
||||
**Domain-Specific Solutions:**
|
||||
- 🏥 **Healthcare** — Clinical decision support, patient safety, compliance
|
||||
- � **Finance** — Fraud detection, regulatory compliance, risk management
|
||||
- ⚖️ **Legal** — Evidence analysis, contract review, case law reasoning
|
||||
- � **Cybersecurity** — Threat attribution, incident response, provenance
|
||||
- 🏛️ **Government** — Policy tracking, classified information governance
|
||||
|
||||
**Custom Solutions Available:**
|
||||
- � Private cloud deployment
|
||||
- 🛡️ Enterprise security compliance
|
||||
- � Custom analytics and reporting
|
||||
- 🎯 Professional implementation services
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
All contributions welcome — bug fixes, features, tests, and docs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Benchmark Tools
|
||||
|
||||
pytest>=7.0.0
|
||||
pytest-benchmark>=4.0.0
|
||||
pytest-benchmark>=5.2.3
|
||||
|
||||
# Core Utils
|
||||
|
||||
|
||||
Generated
+45
@@ -19,6 +19,7 @@
|
||||
"graphology-metrics": "^2.4.0",
|
||||
"graphology-shortest-path": "^2.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"playwright": "^1.59.1",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -3467,6 +3468,50 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"graphology-metrics": "^2.4.0",
|
||||
"graphology-shortest-path": "^2.1.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"playwright": "^1.59.1",
|
||||
"react": "^19.2.4",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
+980
-7
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/* ── Semantica Explorer — Global CSS Reset ── */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;600;700;800&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
@@ -12,7 +12,7 @@ html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'IBM Plex Sans', 'Space Grotesk', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #0d1117;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GraphArrowVisibilityPolicy,
|
||||
GraphBadgeKind,
|
||||
GraphEdgeVariant,
|
||||
GraphEntityShapeVariant,
|
||||
GraphLabelVisibilityPolicy,
|
||||
GraphNodeShapeVariant,
|
||||
} from "../workspaces/GraphWorkspace/graphTheme";
|
||||
@@ -36,6 +37,7 @@ export interface NodeAttributes {
|
||||
borderSize?: number;
|
||||
nodeVariant?: GraphNodeShapeVariant;
|
||||
nodeShapeVariant?: GraphNodeShapeVariant;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeCount?: number;
|
||||
ringColor?: string;
|
||||
|
||||
@@ -25,8 +25,12 @@ import {
|
||||
collectInteractionRefreshTargets,
|
||||
createInteractionState,
|
||||
isEdgeInteractable,
|
||||
classifyFullGraphEdge,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveNodeElementStyle,
|
||||
resolveNodeVisualState,
|
||||
} from "./graphSceneState";
|
||||
@@ -47,15 +51,30 @@ import {
|
||||
drawSemanticaNodeHover,
|
||||
drawSemanticaNodeLabel,
|
||||
} from "./sigmaNativeRendering";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
clearGraphStructureLayer,
|
||||
createGraphStructureCacheKey,
|
||||
drawGraphStructureLayer,
|
||||
evaluateGraphStructureLayerGate,
|
||||
getGraphStructureLayerDiagnostics,
|
||||
type GraphStructureCurveCache,
|
||||
} from "./graphStructureLayer";
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassCounts,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -85,6 +104,7 @@ export interface GraphCanvasProps {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
@@ -98,7 +118,7 @@ export interface GraphCanvasProps {
|
||||
onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
}
|
||||
|
||||
@@ -800,6 +820,7 @@ function drawNodeBadge(
|
||||
}
|
||||
|
||||
type ReducerSceneState = {
|
||||
viewMode: GraphViewMode;
|
||||
zoomTier: GraphZoomTier;
|
||||
hoveredNodeId: string | null;
|
||||
selectedNodeId: string;
|
||||
@@ -810,15 +831,135 @@ type ReducerSceneState = {
|
||||
edgeEndpointIds: Set<string>;
|
||||
pathNodeIds: Set<string>;
|
||||
pathEdgeIds: Set<string>;
|
||||
highlightedIncidentEdgeIds: Set<string>;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
};
|
||||
|
||||
const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
|
||||
"hidden",
|
||||
"backbone",
|
||||
"bridge",
|
||||
"local-context",
|
||||
"selected",
|
||||
"path",
|
||||
"muted",
|
||||
];
|
||||
|
||||
function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
|
||||
return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
|
||||
counts[edgeClass] = 0;
|
||||
return counts;
|
||||
}, {} as GraphFullEdgeClassCounts);
|
||||
}
|
||||
|
||||
function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
|
||||
return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
|
||||
}
|
||||
|
||||
function scoreIncidentEdge(
|
||||
attrs: EdgeAttributes,
|
||||
edgeId: string,
|
||||
otherEndpointId: string,
|
||||
visibleNeighborIds: Set<string>,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
selectedEdgeId: string,
|
||||
): number {
|
||||
if (pathEdgeIds.has(edgeId)) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
if (selectedEdgeId && edgeId === selectedEdgeId) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
|
||||
const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
|
||||
const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
|
||||
const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
|
||||
|
||||
return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
|
||||
+ (focusIds.has(otherEndpointId) ? 1.25 : 0)
|
||||
+ visualPriority * 2
|
||||
+ normalizedWeight * 1.4
|
||||
+ relationshipStrength;
|
||||
}
|
||||
|
||||
function buildHighlightedIncidentEdgeIds(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
displayState: GraphDisplayStateSnapshot | undefined,
|
||||
focusIds: Set<string>,
|
||||
pathEdgeIds: Set<string>,
|
||||
): Set<string> {
|
||||
const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
|
||||
if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
|
||||
if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const visibleNeighborIds = new Set(
|
||||
(displayState?.selectedVisibleNeighborIds ?? [])
|
||||
.filter((nodeId) => displayGraph.hasNode(nodeId)),
|
||||
);
|
||||
const candidates: Array<{ edgeId: string; score: number }> = [];
|
||||
|
||||
displayGraph.edges(primaryNodeId).forEach((edge) => {
|
||||
const edgeId = String(edge);
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
|
||||
const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
|
||||
candidates.push({
|
||||
edgeId,
|
||||
score: scoreIncidentEdge(
|
||||
attrs,
|
||||
edgeId,
|
||||
otherEndpointId,
|
||||
visibleNeighborIds,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
interactionState.selectedEdgeId,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const selected = new Set<string>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (
|
||||
candidate.edgeId === interactionState.selectedEdgeId
|
||||
|| pathEdgeIds.has(candidate.edgeId)
|
||||
|| selected.size < cap
|
||||
) {
|
||||
selected.add(candidate.edgeId);
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
}
|
||||
|
||||
function buildReducerSceneState(
|
||||
displayGraph: GraphSceneGraph,
|
||||
interactionState: GraphInteractionState,
|
||||
analyticsSnapshot: GraphAnalyticsSnapshot | null,
|
||||
displayState?: GraphDisplayStateSnapshot,
|
||||
analyticsSnapshot?: GraphAnalyticsSnapshot | null,
|
||||
distanceVisualState?: GraphDistanceVisualState,
|
||||
): ReducerSceneState {
|
||||
const { zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
|
||||
const primaryNodeId = hoveredNodeId || selectedNodeId;
|
||||
const focusIds = primaryNodeId
|
||||
? (
|
||||
@@ -827,8 +968,10 @@ function buildReducerSceneState(
|
||||
: new Set<string>()
|
||||
)
|
||||
: new Set<string>();
|
||||
const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
zoomTier,
|
||||
hoveredNodeId,
|
||||
selectedNodeId,
|
||||
@@ -838,8 +981,75 @@ function buildReducerSceneState(
|
||||
focusIds,
|
||||
edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
|
||||
pathNodeIds: new Set(activePath),
|
||||
pathEdgeIds: buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds),
|
||||
pathEdgeIds,
|
||||
overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
|
||||
distanceVisualState,
|
||||
highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
|
||||
displayGraph,
|
||||
interactionState,
|
||||
displayState,
|
||||
focusIds,
|
||||
pathEdgeIds,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getFullGraphEdgeClass(
|
||||
displayGraph: GraphSceneGraph,
|
||||
edgeId: string,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClass {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return "hidden";
|
||||
}
|
||||
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceAttrs = displayGraph.hasNode(sourceId)
|
||||
? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
|
||||
: undefined;
|
||||
const targetAttrs = displayGraph.hasNode(targetId)
|
||||
? displayGraph.getNodeAttributes(targetId) as NodeAttributes
|
||||
: undefined;
|
||||
|
||||
return classifyFullGraphEdge(
|
||||
edgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
sourceAttrs,
|
||||
targetAttrs,
|
||||
);
|
||||
}
|
||||
|
||||
function buildFullGraphEdgeClassDiagnostics(
|
||||
displayGraph: GraphSceneGraph,
|
||||
currentState: ReducerSceneState,
|
||||
): GraphFullEdgeClassDiagnostics {
|
||||
const counts = createFullEdgeClassCounts();
|
||||
|
||||
displayGraph.forEachEdge((edgeId) => {
|
||||
const edgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
|
||||
: "hidden";
|
||||
counts[edgeClass] += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
mode: currentState.viewMode,
|
||||
zoomTier: currentState.zoomTier,
|
||||
totalEdges: displayGraph.size,
|
||||
visibleEdges: displayGraph.size - counts.hidden,
|
||||
counts,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -889,21 +1099,34 @@ function applySceneState(
|
||||
data.label,
|
||||
cameraRatio,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceNodeStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(node),
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
color: style.color,
|
||||
shellColor: style.shellColor,
|
||||
coreScale: style.coreScale,
|
||||
size: style.size,
|
||||
forceLabel: style.forceLabel,
|
||||
label: style.label,
|
||||
zIndex: style.zIndex,
|
||||
hidden: style.hidden,
|
||||
borderColor: style.borderColor,
|
||||
borderSize: style.borderSize,
|
||||
ringColor: style.showRing ? style.ringColor : style.borderColor,
|
||||
ringSize: style.ringSize,
|
||||
color: resolvedStyle.color,
|
||||
shellColor: resolvedStyle.shellColor,
|
||||
coreScale: resolvedStyle.coreScale,
|
||||
size: resolvedStyle.size,
|
||||
forceLabel: resolvedStyle.forceLabel,
|
||||
label: resolvedStyle.label,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
hidden: resolvedStyle.hidden,
|
||||
borderColor: resolvedStyle.borderColor,
|
||||
borderSize: resolvedStyle.borderSize,
|
||||
ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
|
||||
ringSize: resolvedStyle.ringSize,
|
||||
entityShape: resolvedStyle.entityShape,
|
||||
entityShapeKind: resolvedStyle.entityShapeKind,
|
||||
entityAspectRatio: resolvedStyle.entityAspectRatio,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -927,18 +1150,35 @@ function applySceneState(
|
||||
const attrs = data as EdgeAttributes;
|
||||
const [source, target] = currentGraph.extremities(edge);
|
||||
const stableEdgeId = String(edge);
|
||||
const state = resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
source,
|
||||
target,
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.overviewBackboneEdgeIds,
|
||||
const hasActiveInteraction = Boolean(
|
||||
currentState.hoveredNodeId
|
||||
|| currentState.selectedNodeId
|
||||
|| currentState.selectedEdgeId
|
||||
|| currentState.pathEdgeIds.size > 0,
|
||||
);
|
||||
const fullEdgeClass = currentState.viewMode === "full"
|
||||
? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
|
||||
: undefined;
|
||||
const state = currentState.viewMode === "full"
|
||||
? mapFullEdgeClassToVisualState(
|
||||
fullEdgeClass ?? "hidden",
|
||||
{
|
||||
hoveredNodeId: currentState.hoveredNodeId,
|
||||
hasActiveInteraction,
|
||||
},
|
||||
)
|
||||
: resolveEdgeVisualState(
|
||||
stableEdgeId,
|
||||
String(source),
|
||||
String(target),
|
||||
currentState.zoomTier,
|
||||
currentState.hoveredNodeId,
|
||||
currentState.selectedNodeId,
|
||||
currentState.selectedEdgeId,
|
||||
currentState.focusIds,
|
||||
currentState.pathEdgeIds,
|
||||
currentState.highlightedIncidentEdgeIds,
|
||||
);
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
currentState.zoomTier,
|
||||
@@ -946,16 +1186,29 @@ function applySceneState(
|
||||
attrs,
|
||||
source,
|
||||
target,
|
||||
currentState.viewMode,
|
||||
stableEdgeId,
|
||||
fullEdgeClass,
|
||||
);
|
||||
const distanceStyle = currentState.viewMode === "full"
|
||||
? resolveDistanceEdgeStyle(
|
||||
style,
|
||||
currentState.distanceVisualState,
|
||||
String(source),
|
||||
String(target),
|
||||
fullEdgeClass,
|
||||
)
|
||||
: {};
|
||||
const resolvedStyle = { ...style, ...distanceStyle };
|
||||
|
||||
return {
|
||||
...data,
|
||||
hidden: style.hidden,
|
||||
type: style.type,
|
||||
color: style.color,
|
||||
size: style.size,
|
||||
zIndex: style.zIndex,
|
||||
curvature: style.curvature,
|
||||
hidden: resolvedStyle.hidden,
|
||||
type: resolvedStyle.type,
|
||||
color: resolvedStyle.color,
|
||||
size: resolvedStyle.size,
|
||||
zIndex: resolvedStyle.zIndex,
|
||||
curvature: resolvedStyle.curvature,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -999,6 +1252,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
selectedEdgeId,
|
||||
activePath = [],
|
||||
activePathEdgeIds = [],
|
||||
distanceVisualState,
|
||||
effectsState,
|
||||
temporalState,
|
||||
isLayoutRunning,
|
||||
@@ -1017,6 +1271,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const overlayRef = useRef<HTMLCanvasElement>(null);
|
||||
const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
|
||||
const structureLayerLastDrawAtRef = useRef<number | null>(null);
|
||||
const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
|
||||
const sigmaRef = useRef<Sigma | null>(null);
|
||||
const fa2Ref = useRef<FA2Layout | null>(null);
|
||||
const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
|
||||
@@ -1029,6 +1287,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const graphVersionRef = useRef(graphVersion);
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
const focusedNodeIdRef = useRef(focusedNodeId);
|
||||
const distanceVisualStateRef = useRef(distanceVisualState);
|
||||
const viewModeRef = useRef(viewMode);
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onEdgeClickRef = useRef(onEdgeClick);
|
||||
@@ -1037,6 +1296,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
|
||||
const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
|
||||
const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
|
||||
const appliedGraphVersionRef = useRef<number | null>(null);
|
||||
const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
|
||||
const layoutSyncFrameRef = useRef<number | null>(null);
|
||||
@@ -1054,6 +1314,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersionRef.current = graphVersion;
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
focusedNodeIdRef.current = focusedNodeId;
|
||||
distanceVisualStateRef.current = distanceVisualState;
|
||||
viewModeRef.current = viewMode;
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onEdgeClickRef.current = onEdgeClick;
|
||||
@@ -1100,6 +1361,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const interactionStateRef = useRef<GraphInteractionState>(interactionState);
|
||||
interactionStateRef.current = interactionState;
|
||||
const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
|
||||
const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!isLayoutRunning) {
|
||||
setLayoutSettledEpoch((epoch) => epoch + 1);
|
||||
}
|
||||
}, [displayGraph, graphVersion, isLayoutRunning]);
|
||||
|
||||
const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
|
||||
const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
|
||||
const analyticsBase = useMemo(
|
||||
@@ -1110,11 +1378,55 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[displayGraph, shouldComputeCentrality, shouldComputeCommunities],
|
||||
);
|
||||
const reducerSceneState = useMemo(
|
||||
() => buildReducerSceneState(displayGraph, interactionState, analyticsSnapshot),
|
||||
[analyticsSnapshot, displayGraph, interactionState],
|
||||
() => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
|
||||
[analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
|
||||
);
|
||||
const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
|
||||
reducerSceneStateRef.current = reducerSceneState;
|
||||
const edgeClassDiagnostics = useMemo(
|
||||
() => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
|
||||
[displayGraph, reducerSceneState],
|
||||
);
|
||||
const structureLayerGate = useMemo(
|
||||
() => evaluateGraphStructureLayerGate({
|
||||
mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics: edgeClassDiagnostics,
|
||||
minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
|
||||
}),
|
||||
[edgeClassDiagnostics, isLayoutRunning, viewMode],
|
||||
);
|
||||
const structureLayerCache = useMemo(() => {
|
||||
if (!structureLayerGate.enabled) {
|
||||
return null;
|
||||
}
|
||||
const cacheKey = createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
|
||||
});
|
||||
return buildGraphStructureCurveCache({
|
||||
graphRef: displayGraph,
|
||||
cacheKey,
|
||||
classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
|
||||
maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
|
||||
curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
|
||||
});
|
||||
}, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
|
||||
structureLayerCacheRef.current = structureLayerCache;
|
||||
const structureLayerDiagnostics = useMemo(
|
||||
() => getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache: structureLayerCache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(structureLayerCanvasRef.current),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
}),
|
||||
[structureLayerCache, structureLayerGate],
|
||||
);
|
||||
structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
|
||||
const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
|
||||
graphVersion,
|
||||
viewMode,
|
||||
@@ -1237,17 +1549,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = computeGraphSpaceBounds(currentDisplayGraph, selectionNodeIds);
|
||||
const selectedDisplayNodeId = selectionNodeIds[0];
|
||||
const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
|
||||
if (selectedDisplayData) {
|
||||
const viewportPoint = sigma.graphToViewport({
|
||||
x: selectedDisplayData.x,
|
||||
y: selectedDisplayData.y,
|
||||
});
|
||||
const dimensions = sigma.getDimensions();
|
||||
if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
|
||||
debugGraphRuntime("camera-selection-visible-noop", {
|
||||
nodeId,
|
||||
selectedDisplayNodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
viewportX: viewportPoint.x,
|
||||
viewportY: viewportPoint.y,
|
||||
});
|
||||
sigma.scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
|
||||
if (!bounds) {
|
||||
if (attempt < 3) {
|
||||
debugGraphRuntime("camera-selection-deferred", {
|
||||
debugGraphRuntime("camera-selection-display-bounds-deferred", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
attempt: attempt + 1,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(deferredFocusFrameRef.current);
|
||||
@@ -1257,45 +1592,43 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
centerSelectionInViewInternal(nodeId, attempt + 1);
|
||||
});
|
||||
} else {
|
||||
debugGraphRuntime("camera-selection-fallback-fit", {
|
||||
debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
viewMode: viewModeRef.current,
|
||||
contextCount: selectionNodeIds.length,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const globalBounds = computeDisplayedGraphBounds(sigma, currentDisplayGraph);
|
||||
const selectionBBox = expandGraphSpaceBounds(bounds, globalBounds, {
|
||||
paddingRatio: 0.2,
|
||||
minSpanRatio: 0.035,
|
||||
minSpanFloor: 0.02,
|
||||
});
|
||||
if (!selectionBBox) {
|
||||
debugGraphRuntime("camera-selection-invalid-bounds", {
|
||||
nodeId,
|
||||
graphVersion: graphVersionRef.current,
|
||||
count: bounds.count,
|
||||
});
|
||||
fitDisplayGraphInView();
|
||||
return;
|
||||
}
|
||||
const camera = sigma.getCamera();
|
||||
const currentCameraState = camera.getState();
|
||||
const target = {
|
||||
x: (bounds.minX + bounds.maxX) / 2,
|
||||
y: (bounds.minY + bounds.maxY) / 2,
|
||||
ratio: currentCameraState.ratio,
|
||||
angle: currentCameraState.angle,
|
||||
};
|
||||
|
||||
animateCameraToBounds("fit-selection-context", selectionBBox, {
|
||||
debugGraphRuntime("camera-selection-gentle-center", {
|
||||
nodeId,
|
||||
contextCount: bounds.count,
|
||||
boundsSource: "displayed-selection-context",
|
||||
minX: bounds.minX,
|
||||
maxX: bounds.maxX,
|
||||
minY: bounds.minY,
|
||||
maxY: bounds.maxY,
|
||||
referenceMinX: globalBounds?.minX ?? null,
|
||||
referenceMaxX: globalBounds?.maxX ?? null,
|
||||
referenceMinY: globalBounds?.minY ?? null,
|
||||
referenceMaxY: globalBounds?.maxY ?? null,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
preservedRatio: target.ratio,
|
||||
});
|
||||
}, [animateCameraToBounds, fitDisplayGraphInView]);
|
||||
|
||||
void camera.animate(
|
||||
target,
|
||||
{ duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
|
||||
);
|
||||
}, []);
|
||||
|
||||
const centerGroupedSelectionInView = useCallback((nodeId: string) => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -1580,6 +1913,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const structureCanvas = sigma.createCanvas("structure", {
|
||||
beforeLayer: "nodes",
|
||||
afterLayer: "edges",
|
||||
style: {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
structureLayerCanvasRef.current = structureCanvas;
|
||||
} catch (error) {
|
||||
debugGraphRuntime("structure-layer-create-failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
structureLayerCanvasRef.current = null;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
syncCameraState(sigma);
|
||||
});
|
||||
@@ -1607,6 +1956,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
debugGraphRuntime("sigma-killed", {
|
||||
graphVersion: graphVersionRef.current,
|
||||
});
|
||||
if (structureLayerCanvasRef.current) {
|
||||
try {
|
||||
sigma.killLayer("structure");
|
||||
} catch {
|
||||
// Sigma.kill() also cleans layers; ignore if already removed.
|
||||
}
|
||||
}
|
||||
structureLayerCanvasRef.current = null;
|
||||
sigma.kill();
|
||||
}
|
||||
if (deferredFocusFrameRef.current !== null) {
|
||||
@@ -1649,6 +2006,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
appliedGraphVersionRef.current = graphVersion;
|
||||
fittedDisplaySignatureRef.current = null;
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
behaviorContextRef.current = getBehaviorContext(sigma);
|
||||
if (runtimeRef.current) {
|
||||
runtimeRef.current.displayGraph = displayGraph;
|
||||
@@ -1764,11 +2122,30 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
container?.clientWidth ?? 0,
|
||||
container?.clientHeight ?? 0,
|
||||
);
|
||||
onDiagnosticsChange(availability);
|
||||
}, [analyticsSnapshot, effectsState, interactionState, isLayoutRunning, onDiagnosticsChange, temporalState]);
|
||||
onDiagnosticsChange({
|
||||
effectAvailability: availability,
|
||||
edgeClasses: edgeClassDiagnostics,
|
||||
structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
|
||||
distanceVisual: distanceVisualStateRef.current,
|
||||
});
|
||||
if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
|
||||
console.debug("[Edge Truth]", edgeClassDiagnostics);
|
||||
}
|
||||
}, [
|
||||
analyticsSnapshot,
|
||||
edgeClassDiagnostics,
|
||||
effectsState,
|
||||
interactionState,
|
||||
isLayoutRunning,
|
||||
onDiagnosticsChange,
|
||||
structureLayerDiagnostics,
|
||||
temporalState,
|
||||
distanceVisualState,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
previousInteractionStateRef.current = null;
|
||||
previousDistanceVisualStateRef.current = undefined;
|
||||
}, [displayGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1778,13 +2155,68 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
}
|
||||
|
||||
const previousInteractionState = previousInteractionStateRef.current;
|
||||
const refreshTargets = previousInteractionState
|
||||
const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
|
||||
const refreshTargets = !distanceVisualStateChanged && previousInteractionState
|
||||
? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
|
||||
: undefined;
|
||||
|
||||
applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
|
||||
previousInteractionStateRef.current = interactionState;
|
||||
}, [displayGraph, interactionState, reducerSceneStateRef]);
|
||||
previousDistanceVisualStateRef.current = distanceVisualState;
|
||||
}, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
|
||||
|
||||
const drawStructureLayerFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
const canvas = structureLayerCanvasRef.current;
|
||||
const cache = structureLayerCacheRef.current;
|
||||
const diagnostics = getGraphStructureLayerDiagnostics({
|
||||
gate: structureLayerGate,
|
||||
cache,
|
||||
minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
|
||||
canvasAvailable: Boolean(canvas),
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
});
|
||||
structureLayerDiagnosticsRef.current = diagnostics;
|
||||
|
||||
if (!sigma || !canvas || !diagnostics.enabled || !cache) {
|
||||
clearGraphStructureLayer(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
const drawn = drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
});
|
||||
if (drawn) {
|
||||
structureLayerLastDrawAtRef.current = Date.now();
|
||||
structureLayerDiagnosticsRef.current = {
|
||||
...diagnostics,
|
||||
lastDrawAt: structureLayerLastDrawAtRef.current,
|
||||
};
|
||||
}
|
||||
}, [structureLayerGate]);
|
||||
|
||||
useEffect(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
if (!graphReady || !sigma) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draw = () => drawStructureLayerFrame();
|
||||
sigma.on("afterRender", draw);
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
sigma.off("afterRender", draw);
|
||||
};
|
||||
}, [drawStructureLayerFrame, graphReady, structureLayerCache]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLayoutRunning || viewMode !== "full") {
|
||||
clearGraphStructureLayer(structureLayerCanvasRef.current);
|
||||
}
|
||||
}, [isLayoutRunning, viewMode]);
|
||||
|
||||
const drawOverlayFrame = useCallback(() => {
|
||||
const sigma = sigmaRef.current;
|
||||
@@ -1847,6 +2279,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
...pathNodeIds,
|
||||
...(primaryNodeId ? [primaryNodeId] : []),
|
||||
]);
|
||||
const lensFocusIds = new Set<string>();
|
||||
if (primaryNodeId) {
|
||||
reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
|
||||
if (!displayGraph.hasEdge(edgeId)) {
|
||||
return;
|
||||
}
|
||||
const [source, target] = displayGraph.extremities(edgeId);
|
||||
const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
|
||||
if (displayGraph.hasNode(otherEndpointId)) {
|
||||
lensFocusIds.add(otherEndpointId);
|
||||
}
|
||||
});
|
||||
}
|
||||
const now = performance.now() / 1000;
|
||||
|
||||
if (!isLayoutRunning) {
|
||||
@@ -1909,7 +2354,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
});
|
||||
|
||||
if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
|
||||
drawLensLayer(context, sigma, primaryNodeId, focusIds);
|
||||
drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
|
||||
}
|
||||
|
||||
drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { graph } from "../../store/graphStore";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type { GraphSelectedNodeKind } from "./types";
|
||||
|
||||
export type LinkPrediction = {
|
||||
@@ -17,6 +17,13 @@ export type PathResponse = {
|
||||
total_weight: number;
|
||||
hop_count: number;
|
||||
distance_band: "direct" | "near" | "mid-range" | "distant";
|
||||
// FR-1 distance intelligence enrichment
|
||||
semantic_similarity?: number | null;
|
||||
path_coherence_score?: number | null;
|
||||
confidence_decay?: number | null;
|
||||
bottleneck_node?: string | null;
|
||||
alternative_path_count?: number;
|
||||
interpretation?: string;
|
||||
};
|
||||
|
||||
export interface GraphInspectorPanelProps {
|
||||
@@ -46,6 +53,129 @@ function sourceAttribution(properties: Record<string, unknown>) {
|
||||
.map((key) => ({ key, value: properties[key] }));
|
||||
}
|
||||
|
||||
/* ─── Path Distance Intelligence Panel ──────────────────────────── */
|
||||
|
||||
const BAND_COLORS: Record<string, string> = {
|
||||
direct: "#3fb950",
|
||||
near: "#79c0ff",
|
||||
"mid-range": "#e3b341",
|
||||
distant: "#ff7b72",
|
||||
};
|
||||
|
||||
function PathDistanceIntelPanel({ result }: { result: PathResponse }) {
|
||||
const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e";
|
||||
const hasMetrics =
|
||||
result.confidence_decay != null ||
|
||||
result.semantic_similarity != null ||
|
||||
result.path_coherence_score != null ||
|
||||
result.bottleneck_node != null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
{/* distance band + alt paths */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<span
|
||||
style={{
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: withAlpha(bandColor, 0.14),
|
||||
border: `1px solid ${withAlpha(bandColor, 0.3)}`,
|
||||
color: bandColor,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{(result.alternative_path_count ?? 0) > 0 && (
|
||||
<span style={subtleChipStyle}>{result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* metric grid */}
|
||||
{hasMetrics && <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{result.confidence_decay != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Confidence Decay</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
>
|
||||
{(result.confidence_decay * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div
|
||||
style={{
|
||||
...metricBarFillStyle,
|
||||
width: `${result.confidence_decay * 100}%`,
|
||||
background:
|
||||
result.confidence_decay > 0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.semantic_similarity != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Semantic Sim.</div>
|
||||
<div style={{ ...metricValueStyle, color: "#79c0ff" }}>
|
||||
{(result.semantic_similarity * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={metricBarTrackStyle}>
|
||||
<div style={{ ...metricBarFillStyle, width: `${result.semantic_similarity * 100}%`, background: "#79c0ff" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.path_coherence_score != null && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Path Coherence</div>
|
||||
<div style={{ ...metricValueStyle, color: "#a5d6a7" }}>
|
||||
{(result.path_coherence_score * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.bottleneck_node && (
|
||||
<div style={metricCardStyle}>
|
||||
<div style={metricLabelStyle}>Bottleneck</div>
|
||||
<div
|
||||
style={{
|
||||
...metricValueStyle,
|
||||
color: "#e3b341",
|
||||
fontSize: 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={result.bottleneck_node}
|
||||
>
|
||||
{getNodeLabel(result.bottleneck_node)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* interpretation */}
|
||||
{result.interpretation && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
background: "rgba(88,166,255,0.06)",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(88,166,255,0.14)",
|
||||
color: "#a0b4cc",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{result.interpretation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Path Flow Visualizer ──────────────────────────────────────── */
|
||||
|
||||
function getNodeLabel(nodeId: string): string {
|
||||
@@ -83,11 +213,13 @@ function PathFlowViz({
|
||||
path,
|
||||
edgeIds,
|
||||
totalWeight,
|
||||
bottleneckNodeId,
|
||||
onFocusNode,
|
||||
}: {
|
||||
path: string[];
|
||||
edgeIds?: string[];
|
||||
totalWeight: number;
|
||||
bottleneckNodeId?: string | null;
|
||||
onFocusNode?: (nodeId: string) => void;
|
||||
}) {
|
||||
if (path.length === 0) {
|
||||
@@ -110,10 +242,13 @@ function PathFlowViz({
|
||||
{/* Node chip */}
|
||||
<button
|
||||
onClick={() => onFocusNode?.(nodeId)}
|
||||
title={`Focus: ${nodeId}`}
|
||||
title={nodeId === bottleneckNodeId ? `Bottleneck: ${nodeId}` : `Focus: ${nodeId}`}
|
||||
style={{
|
||||
...pathNodeChipStyle,
|
||||
cursor: onFocusNode ? "pointer" : "default",
|
||||
...(nodeId === bottleneckNodeId
|
||||
? { border: "1px solid rgba(227,179,65,0.5)", background: "rgba(227,179,65,0.12)" }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span style={pathNodeIndexStyle}>{index + 1}</span>
|
||||
@@ -172,10 +307,10 @@ export function GraphInspectorPanel({
|
||||
if (!nodeId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", gap: 12, marginTop: 32 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(74,163,255,0.08)", border: "1px solid rgba(74,163,255,0.14)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: "rgba(127,208,255,0.3)" }} />
|
||||
<div style={{ width: 40, height: 40, borderRadius: "50%", background: "rgba(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft }} />
|
||||
</div>
|
||||
<p style={{ color: "#8b949e", fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
<p style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 14, margin: 0, lineHeight: 1.6 }}>
|
||||
Search for a node or click one in the canvas to inspect its properties.
|
||||
</p>
|
||||
</div>
|
||||
@@ -191,19 +326,19 @@ export function GraphInspectorPanel({
|
||||
if (!effectiveNodeId) {
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: "#58a6ff", boxShadow: "0 0 10px rgba(88,166,255,0.45)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: "#58a6ff", fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
<span style={{ background: GRAPH_THEME.ui.timeline.playhead, boxShadow: "0 0 10px rgba(98, 226, 205, 0.34)", width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 12, fontWeight: 700 }}>Selection</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{nodeId}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>{nodeId}</div>
|
||||
</div>
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>Selected item is not directly inspectable in the current graph.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? "Activate Focused mode to resolve this grouped selection to its canonical node."
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -233,23 +368,23 @@ export function GraphInspectorPanel({
|
||||
return (
|
||||
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{/* Node identity */}
|
||||
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.2)", paddingBottom: 16 }}>
|
||||
<div style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.divider}`, paddingBottom: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
||||
<span style={{ color: accentColor, fontSize: 12, fontWeight: 700 }}>
|
||||
{groupedDisplaySelection ? "Grouped Selection" : (attributes?.nodeType || "Entity")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, color: "#fff", fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
<h3 style={{ margin: 0, color: GRAPH_THEME.ui.text.strong, fontSize: 20, fontWeight: 700, wordBreak: "break-word" }}>
|
||||
{String(attributes?.label ?? effectiveNodeId)}
|
||||
</h3>
|
||||
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12, marginTop: 6, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{groupedDisplaySelection ? nodeId : effectiveNodeId}
|
||||
</div>
|
||||
{groupedDisplaySelection ? (
|
||||
<div style={groupedSelectionNoticeStyle}>
|
||||
<div style={{ color: "#dbe9f7", fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.6 }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600, marginBottom: 6 }}>This grouped item stays display-level until you explicitly enter Focused mode.</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{canActivateFocused
|
||||
? `Canonical node available: ${effectiveNodeId}`
|
||||
: (focusedUnavailableReason ?? "Focused mode is unavailable for the current selection.")}
|
||||
@@ -267,7 +402,7 @@ export function GraphInspectorPanel({
|
||||
|
||||
{/* Temporal bounds */}
|
||||
{(attributes?.valid_from || attributes?.valid_until) ? (
|
||||
<div style={{ padding: "10px 12px", background: "rgba(88,166,255,0.08)", border: "1px solid rgba(88,166,255,0.2)", borderRadius: 8, fontSize: 12, color: "#79c0ff", fontFamily: "monospace" }}>
|
||||
<div style={{ padding: "10px 12px", background: "rgba(233, 196, 122, 0.075)", border: "1px solid rgba(233, 196, 122, 0.22)", borderRadius: 8, fontSize: 12, color: GRAPH_THEME.palette.accent.selected, fontFamily: "monospace" }}>
|
||||
{attributes?.valid_from ? <div>from: {attributes.valid_from}</div> : null}
|
||||
{attributes?.valid_until ? <div>until: {attributes.valid_until}</div> : null}
|
||||
</div>
|
||||
@@ -316,12 +451,16 @@ export function GraphInspectorPanel({
|
||||
<button style={actionButtonStyle} onClick={onTracePath} disabled={!actionNodeId}>Trace Causal Path</button>
|
||||
|
||||
{pathResult?.path?.length ? (
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<>
|
||||
<PathFlowViz
|
||||
path={pathResult.path}
|
||||
edgeIds={pathResult.edge_ids}
|
||||
totalWeight={pathResult.total_weight}
|
||||
bottleneckNodeId={pathResult.bottleneck_node}
|
||||
onFocusNode={onFocusNode}
|
||||
/>
|
||||
<PathDistanceIntelPanel result={pathResult} />
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyTextStyle}>
|
||||
Choose a target or click a candidate prediction to prepare a path trace.
|
||||
@@ -343,8 +482,8 @@ export function GraphInspectorPanel({
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.strong, fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.muted, fontSize: 12 }}>{prediction.type}</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div style={{
|
||||
@@ -352,9 +491,9 @@ export function GraphInspectorPanel({
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(88,166,255,0.12)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#58a6ff",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
}}>
|
||||
{(prediction.score * 100).toFixed(1)}%
|
||||
</div>
|
||||
@@ -382,8 +521,8 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{attribution.map(({ key, value }) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,8 +542,8 @@ export function GraphInspectorPanel({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} style={propertyCardStyle}>
|
||||
<div style={{ color: "rgba(88,166,255,0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>
|
||||
<div style={{ color: GRAPH_THEME.ui.timeline.playhead, fontSize: 11, marginBottom: 4 }}>{key}</div>
|
||||
<div style={{ color: GRAPH_THEME.ui.text.body, fontSize: 13, wordBreak: "break-word" }}>
|
||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,27 +562,27 @@ export function GraphInspectorPanel({
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
background: "rgba(4, 10, 18, 0.5)",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
color: "#edf5ff",
|
||||
background: GRAPH_THEME.ui.control.inputBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
borderRadius: 12,
|
||||
padding: "11px 13px",
|
||||
fontSize: 13,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)",
|
||||
};
|
||||
|
||||
const groupedSelectionNoticeStyle: CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88,166,255,0.08)",
|
||||
border: "1px solid rgba(88,166,255,0.2)",
|
||||
background: "rgba(98, 226, 205, 0.07)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
borderRadius: 12,
|
||||
};
|
||||
|
||||
const actionButtonStyle: CSSProperties = {
|
||||
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
|
||||
color: "#fff",
|
||||
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
|
||||
background: GRAPH_THEME.ui.control.primaryBg,
|
||||
color: GRAPH_THEME.ui.control.primaryText,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
|
||||
borderRadius: 12,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
@@ -452,47 +591,47 @@ const actionButtonStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
|
||||
};
|
||||
|
||||
const secondaryActionButtonStyle: CSSProperties = {
|
||||
...actionButtonStyle,
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
color: "#c6d4e3",
|
||||
background: GRAPH_THEME.ui.control.defaultBg,
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
|
||||
color: GRAPH_THEME.ui.control.defaultText,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const predictionCardStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "10px 12px",
|
||||
background: "rgba(88, 166, 255, 0.08)",
|
||||
border: "1px solid rgba(88, 166, 255, 0.12)",
|
||||
background: "rgba(255, 255, 255, 0.035)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const propertyCardStyle: CSSProperties = {
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
background: "rgba(255, 255, 255, 0.028)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255, 255, 255, 0.05)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const emptyTextStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
const subtleChipStyle: CSSProperties = {
|
||||
background: "rgba(255, 255, 255, 0.04)",
|
||||
color: "#9fb6d2",
|
||||
color: GRAPH_THEME.ui.text.body,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
@@ -500,13 +639,13 @@ const sectionStyle: CSSProperties = {
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))",
|
||||
border: "1px solid rgba(255, 255, 255, 0.06)",
|
||||
background: GRAPH_THEME.ui.surface.cardSubtle,
|
||||
border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
|
||||
borderRadius: 14,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: CSSProperties = {
|
||||
color: "#8b949e",
|
||||
color: GRAPH_THEME.ui.text.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
@@ -527,9 +666,9 @@ const pathNodeChipStyle: CSSProperties = {
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(88,166,255,0.1)",
|
||||
border: "1px solid rgba(88,166,255,0.22)",
|
||||
color: "#e6edf3",
|
||||
background: "rgba(98, 226, 205, 0.08)",
|
||||
border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
|
||||
color: GRAPH_THEME.ui.text.strong,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
maxWidth: 160,
|
||||
@@ -542,8 +681,8 @@ const pathNodeIndexStyle: CSSProperties = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(88,166,255,0.22)",
|
||||
color: "#79c0ff",
|
||||
background: GRAPH_THEME.ui.timeline.playheadSoft,
|
||||
color: GRAPH_THEME.ui.timeline.playhead,
|
||||
fontSize: 9,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
@@ -559,7 +698,7 @@ const pathEdgeConnectorStyle: CSSProperties = {
|
||||
const pathEdgeLabelStyle: CSSProperties = {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: "#6a7f97",
|
||||
color: GRAPH_THEME.ui.text.subtle,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
maxWidth: 70,
|
||||
@@ -567,3 +706,41 @@ const pathEdgeLabelStyle: CSSProperties = {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const metricCardStyle: CSSProperties = {
|
||||
background: "rgba(0,0,0,0.18)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid rgba(255,255,255,0.05)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
};
|
||||
|
||||
const metricLabelStyle: CSSProperties = {
|
||||
color: "rgba(88,166,255,0.65)",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const metricValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: "#e6edf3",
|
||||
};
|
||||
|
||||
const metricBarTrackStyle: CSSProperties = {
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.07)",
|
||||
overflow: "hidden",
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const metricBarFillStyle: CSSProperties = {
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
transition: "width 300ms ease",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -510,8 +510,13 @@ export function GraphWorkspaceShell() {
|
||||
if (!selectedNodeId || !pathTargetId.trim()) return;
|
||||
|
||||
try {
|
||||
const pathParams = new URLSearchParams({
|
||||
source: selectedNodeId,
|
||||
target: pathTargetId.trim(),
|
||||
algorithm: "dijkstra",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`,
|
||||
`/api/graph/path?${pathParams.toString()}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Path lookup failed with status ${response.status}`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DataSet } from "vis-data";
|
||||
import { Timeline } from "vis-timeline";
|
||||
import type { TimelineOptions } from "vis-timeline";
|
||||
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
||||
import { GRAPH_THEME } from "./graphTheme";
|
||||
|
||||
export interface TimelinePanelProps {
|
||||
onTimeChange: (time: Date) => void;
|
||||
@@ -19,35 +20,35 @@ const PLAY_STEP_MONTHS = 6;
|
||||
const VIS_OVERRIDE_CSS = `
|
||||
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
||||
.sem-timeline-wrap .vis-panel { border-color: ${GRAPH_THEME.ui.timeline.border} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text {
|
||||
color: #8b949e !important;
|
||||
color: ${GRAPH_THEME.ui.timeline.text} !important;
|
||||
font-size: 11px !important;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
||||
color: #c9d1d9 !important;
|
||||
color: ${GRAPH_THEME.ui.timeline.textStrong} !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: ${GRAPH_THEME.ui.timeline.gridMinor} !important; }
|
||||
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: ${GRAPH_THEME.ui.timeline.gridMajor} !important; }
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
||||
background: rgba(88, 166, 255, 0.15) !important;
|
||||
background: ${GRAPH_THEME.ui.timeline.playheadSoft} !important;
|
||||
width: 2px !important;
|
||||
cursor: ew-resize !important;
|
||||
z-index: 5 !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
||||
background: #58a6ff !important;
|
||||
color: #0d1117 !important;
|
||||
background: ${GRAPH_THEME.ui.timeline.playhead} !important;
|
||||
color: ${GRAPH_THEME.ui.text.inverse} !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 1px 5px !important;
|
||||
white-space: nowrap !important;
|
||||
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
||||
box-shadow: 0 0 8px rgba(98, 226, 205, 0.45) !important;
|
||||
}
|
||||
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
||||
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
||||
@@ -166,14 +167,14 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
useEffect(() => () => stopPlay(), [stopPlay]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
||||
<style>{VIS_OVERRIDE_CSS}</style>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: `1px solid ${GRAPH_THEME.ui.timeline.border}`, minWidth: 80, flexShrink: 0 }}>
|
||||
<button
|
||||
id="temporal-play-btn"
|
||||
onClick={togglePlay}
|
||||
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? GRAPH_THEME.ui.control.activeBorder : GRAPH_THEME.ui.control.defaultBorder}`, background: isPlaying ? GRAPH_THEME.ui.timeline.playheadSoft : GRAPH_THEME.ui.control.defaultBg, color: GRAPH_THEME.ui.timeline.playhead, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(98, 226, 205, 0.32)" : "none" }}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
@@ -181,12 +182,12 @@ export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelP
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
<span style={{ fontSize: 10, color: isPlaying ? GRAPH_THEME.ui.timeline.playhead : GRAPH_THEME.ui.timeline.text, fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
||||
{displayDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: GRAPH_THEME.ui.text.subtle, textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
||||
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,11 +7,19 @@ export const clickSelectionBehavior: GraphBehavior = {
|
||||
onNodeClick: (context, nodeId) => {
|
||||
context.setHoveredNodeId(nodeId);
|
||||
context.onEdgeSelectionChange("");
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
if (context.getInteractionState().selectedNodeId === nodeId) {
|
||||
context.onNodeSelectionChange("");
|
||||
} else {
|
||||
context.onNodeSelectionChange(nodeId);
|
||||
}
|
||||
},
|
||||
onEdgeClick: (context, edgeId) => {
|
||||
context.setHoveredNodeId(null);
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
if (context.getInteractionState().selectedEdgeId === edgeId) {
|
||||
context.onEdgeSelectionChange("");
|
||||
} else {
|
||||
context.onEdgeSelectionChange(edgeId);
|
||||
}
|
||||
},
|
||||
onStageClick: (context) => {
|
||||
context.setHoveredNodeId(null);
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import type { GraphBehavior } from "./types";
|
||||
|
||||
const SWEEP_TICKS = 6;
|
||||
const SWEEP_INTERVAL_MS = 60;
|
||||
|
||||
export function createPathHighlightBehavior(): GraphBehavior {
|
||||
let lastPathSignature = "";
|
||||
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepGeneration = 0;
|
||||
|
||||
function cancelSweep() {
|
||||
sweepGeneration++;
|
||||
if (sweepTimer !== null) {
|
||||
clearTimeout(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSweep(sigma: { refresh: () => void }, tick: number, gen: number) {
|
||||
if (tick >= SWEEP_TICKS) return;
|
||||
sweepTimer = setTimeout(() => {
|
||||
if (gen !== sweepGeneration) return;
|
||||
sigma.refresh();
|
||||
scheduleSweep(sigma, tick + 1, gen);
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
id: "path-highlight",
|
||||
attach: () => {},
|
||||
detach: () => {
|
||||
detach: (context) => {
|
||||
cancelSweep();
|
||||
lastPathSignature = "";
|
||||
context.sigma.refresh();
|
||||
},
|
||||
onStateChange: (context, interactionState) => {
|
||||
const nextPathSignature = interactionState.activePath.join("::");
|
||||
@@ -16,7 +40,13 @@ export function createPathHighlightBehavior(): GraphBehavior {
|
||||
}
|
||||
|
||||
lastPathSignature = nextPathSignature;
|
||||
cancelSweep();
|
||||
context.sigma.refresh();
|
||||
|
||||
// Animate intermediate nodes lighting up sequentially
|
||||
if (interactionState.activePath.length > 2) {
|
||||
scheduleSweep(context.sigma, 0, sweepGeneration);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,8 +44,18 @@ const MAX_REGION_SUMMARIES = 6;
|
||||
const MAX_CENTRALITY_SUMMARIES = 6;
|
||||
const CENTRALITY_ITERATIONS = 24;
|
||||
const MAX_BACKBONE_ANCHORS = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 2;
|
||||
const MAX_BACKBONE_BRIDGES = 4;
|
||||
const MAX_BACKBONE_CENTRAL_LINKS = 36;
|
||||
const MAX_BACKBONE_BRIDGES = 80;
|
||||
const MAX_BACKBONE_TOTAL_EDGES = 128;
|
||||
const MAX_BACKBONE_EDGES_PER_NODE = 5;
|
||||
const MAX_BACKBONE_PARALLEL_PAIR_EDGES = 2;
|
||||
|
||||
type BackboneCandidate = {
|
||||
edgeId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function getNodeLabel(graphRef: GraphRef, nodeId: string): string {
|
||||
const attrs = graphRef.getNodeAttributes(nodeId) as NodeAttributes;
|
||||
@@ -364,6 +374,61 @@ function scoreBackboneEdge(
|
||||
return weight * 1.4 + (sourceScore + targetScore) * 2.4 + priority * 0.6 + parallelBoost + bidirectionalBoost;
|
||||
}
|
||||
|
||||
function upsertBackboneCandidate(
|
||||
candidates: Map<string, BackboneCandidate>,
|
||||
key: string,
|
||||
candidate: BackboneCandidate,
|
||||
) {
|
||||
const current = candidates.get(key);
|
||||
if (
|
||||
!current
|
||||
|| candidate.score > current.score
|
||||
|| (candidate.score === current.score && candidate.edgeId.localeCompare(current.edgeId) < 0)
|
||||
) {
|
||||
candidates.set(key, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function addRankedBackboneCandidates(
|
||||
selected: BackboneCandidate[],
|
||||
selectedEdgeIds: Set<string>,
|
||||
nodeUseCounts: Map<string, number>,
|
||||
pairUseCounts: Map<string, number>,
|
||||
candidates: Iterable<BackboneCandidate>,
|
||||
maxToAdd: number,
|
||||
) {
|
||||
const ranked = [...candidates].sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
for (const candidate of ranked) {
|
||||
if (selected.length >= MAX_BACKBONE_TOTAL_EDGES || maxToAdd <= 0 || selectedEdgeIds.has(candidate.edgeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairKey = [candidate.source, candidate.target].sort().join("::");
|
||||
if ((nodeUseCounts.get(candidate.source) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((nodeUseCounts.get(candidate.target) ?? 0) >= MAX_BACKBONE_EDGES_PER_NODE) {
|
||||
continue;
|
||||
}
|
||||
if ((pairUseCounts.get(pairKey) ?? 0) >= MAX_BACKBONE_PARALLEL_PAIR_EDGES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(candidate);
|
||||
selectedEdgeIds.add(candidate.edgeId);
|
||||
nodeUseCounts.set(candidate.source, (nodeUseCounts.get(candidate.source) ?? 0) + 1);
|
||||
nodeUseCounts.set(candidate.target, (nodeUseCounts.get(candidate.target) ?? 0) + 1);
|
||||
pairUseCounts.set(pairKey, (pairUseCounts.get(pairKey) ?? 0) + 1);
|
||||
maxToAdd -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function buildOverviewBackboneSnapshot(
|
||||
graphRef: GraphRef,
|
||||
visibleNodeIds: Set<string>,
|
||||
@@ -379,7 +444,10 @@ function buildOverviewBackboneSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
const selected: BackboneCandidate[] = [];
|
||||
const selectedEdgeIds = new Set<string>();
|
||||
const nodeUseCounts = new Map<string, number>();
|
||||
const pairUseCounts = new Map<string, number>();
|
||||
const regionByNode = new Map<string, string>();
|
||||
visibleNodeIds.forEach((nodeId) => {
|
||||
regionByNode.set(nodeId, getNodeSemanticGroup(graphRef, nodeId));
|
||||
@@ -397,7 +465,7 @@ function buildOverviewBackboneSnapshot(
|
||||
.map((summary) => summary.id)
|
||||
.filter((nodeId) => visibleNodeIds.has(nodeId));
|
||||
|
||||
const coreLinkCandidates = new Map<string, { edgeId: string; score: number }>();
|
||||
const coreLinkCandidates = new Map<string, BackboneCandidate>();
|
||||
anchorIds.forEach((anchorId) => {
|
||||
collectNodeIncidentEdges(graphRef, anchorId, visibleNodeIds)
|
||||
.filter((entry) => {
|
||||
@@ -415,60 +483,88 @@ function buildOverviewBackboneSnapshot(
|
||||
const targetRegion = regionByNode.get(entry.target);
|
||||
const bridgeBoost = sourceRegion && targetRegion && sourceRegion !== targetRegion ? 0.28 : 0;
|
||||
const score = scoreBackboneEdge(entry.attrs, entry.source, entry.target, base) + bridgeBoost;
|
||||
const current = coreLinkCandidates.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && entry.edgeId.localeCompare(current.edgeId) < 0)) {
|
||||
coreLinkCandidates.set(pairKey, { edgeId: entry.edgeId, score });
|
||||
}
|
||||
upsertBackboneCandidate(coreLinkCandidates, pairKey, {
|
||||
edgeId: entry.edgeId,
|
||||
source: entry.source,
|
||||
target: entry.target,
|
||||
score,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const bridgeByPair = new Map<string, { edgeId: string; score: number }>();
|
||||
const bridgeCandidates = new Map<string, BackboneCandidate>();
|
||||
const structuralCandidates = new Map<string, BackboneCandidate>();
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRegion = regionByNode.get(source);
|
||||
const targetRegion = regionByNode.get(target);
|
||||
if (!sourceRegion || !targetRegion || sourceRegion === targetRegion) {
|
||||
return;
|
||||
const edgeKey = String(edgeId);
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
const sourceRegion = regionByNode.get(sourceId);
|
||||
const targetRegion = regionByNode.get(targetId);
|
||||
const sourceCommunity = base.communitiesByNode.get(sourceId);
|
||||
const targetCommunity = base.communitiesByNode.get(targetId);
|
||||
const crossesSemanticRegion = Boolean(sourceRegion && targetRegion && sourceRegion !== targetRegion);
|
||||
const crossesCommunity = sourceCommunity !== undefined && targetCommunity !== undefined && sourceCommunity !== targetCommunity;
|
||||
const sourceCentrality = base.centralityByNode.get(sourceId)?.score ?? 0;
|
||||
const targetCentrality = base.centralityByNode.get(targetId)?.score ?? 0;
|
||||
|
||||
const baseScore = scoreBackboneEdge(attrs as EdgeAttributes, sourceId, targetId, base);
|
||||
const semanticBoost = crossesSemanticRegion ? 0.5 : 0;
|
||||
const communityBoost = crossesCommunity ? 0.36 : 0;
|
||||
const topRegionBoost = sourceRegion && targetRegion && (topRegionIds.has(sourceRegion) || topRegionIds.has(targetRegion)) ? 0.32 : 0;
|
||||
const centralityBalance = Math.min(sourceCentrality, targetCentrality) * 1.2;
|
||||
const score = baseScore + semanticBoost + communityBoost + topRegionBoost + centralityBalance;
|
||||
const candidate = {
|
||||
edgeId: edgeKey,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
score,
|
||||
};
|
||||
|
||||
if (crossesSemanticRegion || crossesCommunity) {
|
||||
const bridgeKey = [
|
||||
sourceRegion ?? `community:${sourceCommunity ?? sourceId}`,
|
||||
targetRegion ?? `community:${targetCommunity ?? targetId}`,
|
||||
Math.min(sourceCentrality, targetCentrality).toFixed(4),
|
||||
].sort().join("::");
|
||||
upsertBackboneCandidate(bridgeCandidates, bridgeKey, candidate);
|
||||
}
|
||||
|
||||
if (!topRegionIds.has(sourceRegion) && !topRegionIds.has(targetRegion)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairKey = [sourceRegion, targetRegion].sort().join("::");
|
||||
const score = scoreBackboneEdge(attrs as EdgeAttributes, source, target, base) + 0.36;
|
||||
const current = bridgeByPair.get(pairKey);
|
||||
if (!current || score > current.score || (score === current.score && String(edgeId).localeCompare(current.edgeId) < 0)) {
|
||||
bridgeByPair.set(pairKey, { edgeId: String(edgeId), score });
|
||||
}
|
||||
const pairKey = [sourceId, targetId].sort().join("::");
|
||||
upsertBackboneCandidate(structuralCandidates, pairKey, candidate);
|
||||
});
|
||||
|
||||
[...bridgeByPair.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_BRIDGES)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
bridgeCandidates.values(),
|
||||
MAX_BACKBONE_BRIDGES,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
coreLinkCandidates.values(),
|
||||
MAX_BACKBONE_CENTRAL_LINKS,
|
||||
);
|
||||
addRankedBackboneCandidates(
|
||||
selected,
|
||||
selectedEdgeIds,
|
||||
nodeUseCounts,
|
||||
pairUseCounts,
|
||||
structuralCandidates.values(),
|
||||
MAX_BACKBONE_TOTAL_EDGES - selected.length,
|
||||
);
|
||||
|
||||
[...coreLinkCandidates.values()]
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) {
|
||||
return right.score - left.score;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
})
|
||||
.slice(0, MAX_BACKBONE_CENTRAL_LINKS)
|
||||
.forEach((entry) => selectedEdgeIds.add(entry.edgeId));
|
||||
|
||||
const edgeIds = [...selectedEdgeIds]
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
const edgeIds = selected
|
||||
.map((entry) => entry.edgeId)
|
||||
.filter((edgeId) => graphRef.hasEdge(edgeId));
|
||||
|
||||
return {
|
||||
ready: edgeIds.length > 0,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { GraphEntityShapeVariant } from "./graphTheme";
|
||||
|
||||
export const ENTITY_SHAPE_ALIASES: Array<[GraphEntityShapeVariant, RegExp]> = [
|
||||
["biomolecule", /\b(gene|protein|enzyme|receptor|target|transcript|rna|dna|mirna|biomolecule|peptide)\b/i],
|
||||
["condition", /\b(disease|condition|phenotype|symptom|disorder|syndrome|diagnosis|pathology|trait)\b/i],
|
||||
["compound", /\b(drug|chemical|compound|metabolite|molecule|small[_\s-]?molecule|ligand|therapeutic|medication|substance)\b/i],
|
||||
["process", /\b(pathway|process|mechanism|function|ontology|biological[_\s-]?process|cellular[_\s-]?process|program|module)\b/i],
|
||||
];
|
||||
|
||||
export function classifyEntityShape(
|
||||
nodeType?: string,
|
||||
semanticGroup?: string,
|
||||
content?: string,
|
||||
properties?: Record<string, unknown>,
|
||||
): GraphEntityShapeVariant {
|
||||
const values = [
|
||||
nodeType,
|
||||
semanticGroup,
|
||||
content,
|
||||
String(properties?.type ?? ""),
|
||||
String(properties?.category ?? ""),
|
||||
String(properties?.label ?? ""),
|
||||
]
|
||||
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
||||
.join(" ");
|
||||
|
||||
for (const [shape, pattern] of ENTITY_SHAPE_ALIASES) {
|
||||
if (pattern.test(values)) {
|
||||
return shape;
|
||||
}
|
||||
}
|
||||
|
||||
return "entity";
|
||||
}
|
||||
@@ -266,8 +266,8 @@ function renderDensityField(
|
||||
(GRAPH_THEME.effects.semanticRegions.splatRadius + sample.size * 0.9) * scale,
|
||||
);
|
||||
const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.22)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.08)");
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0.05)");
|
||||
gradient.addColorStop(0.58, "rgba(255,255,255,0.02)");
|
||||
gradient.addColorStop(1, "rgba(255,255,255,0)");
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
import type Graph from "graphology";
|
||||
import type Sigma from "sigma";
|
||||
|
||||
import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import type {
|
||||
GraphFullEdgeClass,
|
||||
GraphFullEdgeClassDiagnostics,
|
||||
GraphInteractionState,
|
||||
GraphStructureLayerDiagnostics,
|
||||
GraphStructureLayerDisabledReason,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
|
||||
type GraphRef = Graph;
|
||||
|
||||
type StructureLayerMode = typeof GRAPH_THEME.edges.fullGraphStructureLayer.mode;
|
||||
|
||||
export type GraphStructureCurve = {
|
||||
edgeId: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: { x: number; y: number };
|
||||
target: { x: number; y: number };
|
||||
edgeClass: Extract<GraphFullEdgeClass, "backbone" | "bridge">;
|
||||
priority: number;
|
||||
curvature: number;
|
||||
};
|
||||
|
||||
export type GraphStructureCurveCache = {
|
||||
cacheKey: string;
|
||||
curves: GraphStructureCurve[];
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGateInput = {
|
||||
mode: StructureLayerMode;
|
||||
viewMode: GraphViewMode;
|
||||
isLayoutRunning: boolean;
|
||||
edgeDiagnostics?: GraphFullEdgeClassDiagnostics;
|
||||
minimumLiteralEdges: number;
|
||||
};
|
||||
|
||||
export type GraphStructureLayerGate = {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
};
|
||||
|
||||
export function evaluateGraphStructureLayerGate({
|
||||
mode,
|
||||
viewMode,
|
||||
isLayoutRunning,
|
||||
edgeDiagnostics,
|
||||
minimumLiteralEdges,
|
||||
}: GraphStructureLayerGateInput): GraphStructureLayerGate {
|
||||
if (mode === "off") {
|
||||
return { enabled: false, disabledReason: "disabled" };
|
||||
}
|
||||
|
||||
if (viewMode !== "full") {
|
||||
return { enabled: false, disabledReason: "non-full-mode" };
|
||||
}
|
||||
|
||||
if (isLayoutRunning) {
|
||||
return { enabled: false, disabledReason: "layout-running" };
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
const literalEdges = (edgeDiagnostics?.counts.backbone ?? 0) + (edgeDiagnostics?.counts.bridge ?? 0);
|
||||
if (literalEdges >= minimumLiteralEdges) {
|
||||
return { enabled: false, disabledReason: "enough-literal-edges" };
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true, disabledReason: null };
|
||||
}
|
||||
|
||||
function isFinitePoint(attrs: NodeAttributes) {
|
||||
return Number.isFinite(Number(attrs.x)) && Number.isFinite(Number(attrs.y));
|
||||
}
|
||||
|
||||
function getEdgePriority(attrs: EdgeAttributes) {
|
||||
return Math.max(0, Math.min(1, Number(attrs.visualPriority ?? attrs.weight ?? 0)));
|
||||
}
|
||||
|
||||
function getCurveSortRank(edgeClass: GraphFullEdgeClass, priority: number) {
|
||||
return (edgeClass === "bridge" ? 2 : 1) + priority;
|
||||
}
|
||||
|
||||
function getDeterministicCurveSign(sourceId: string, targetId: string, edgeId: string) {
|
||||
const seed = `${sourceId}|${targetId}|${edgeId}`;
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash = (hash * 31 + seed.charCodeAt(index)) | 0;
|
||||
}
|
||||
return hash % 2 === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function createGraphStructureCacheKey({
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
overviewBackboneEdgeIds,
|
||||
}: {
|
||||
graphVersion: number;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
layoutSettledEpoch: number;
|
||||
overviewBackboneEdgeIds: Set<string>;
|
||||
}) {
|
||||
return [
|
||||
graphVersion,
|
||||
zoomTier,
|
||||
layoutSettledEpoch,
|
||||
Array.from(overviewBackboneEdgeIds).sort().join(","),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
export function buildGraphStructureCurveCache({
|
||||
graphRef,
|
||||
cacheKey,
|
||||
classifyEdge,
|
||||
maxCurves,
|
||||
curveStrength,
|
||||
}: {
|
||||
graphRef: GraphRef;
|
||||
cacheKey: string;
|
||||
classifyEdge: (edgeId: string) => GraphFullEdgeClass;
|
||||
maxCurves: number;
|
||||
curveStrength: number;
|
||||
}): GraphStructureCurveCache {
|
||||
const candidates: Array<GraphStructureCurve & { rank: number }> = [];
|
||||
|
||||
graphRef.forEachEdge((edgeId, attrs, source, target) => {
|
||||
const stableEdgeId = String(edgeId);
|
||||
const edgeClass = classifyEdge(stableEdgeId);
|
||||
if (edgeClass !== "bridge" && edgeClass !== "backbone") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = String(source);
|
||||
const targetId = String(target);
|
||||
if (!graphRef.hasNode(sourceId) || !graphRef.hasNode(targetId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceAttrs = graphRef.getNodeAttributes(sourceId) as NodeAttributes;
|
||||
const targetAttrs = graphRef.getNodeAttributes(targetId) as NodeAttributes;
|
||||
if (!isFinitePoint(sourceAttrs) || !isFinitePoint(targetAttrs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = getEdgePriority(attrs as EdgeAttributes);
|
||||
candidates.push({
|
||||
edgeId: stableEdgeId,
|
||||
sourceId,
|
||||
targetId,
|
||||
source: { x: Number(sourceAttrs.x), y: Number(sourceAttrs.y) },
|
||||
target: { x: Number(targetAttrs.x), y: Number(targetAttrs.y) },
|
||||
edgeClass,
|
||||
priority,
|
||||
curvature: getDeterministicCurveSign(sourceId, targetId, stableEdgeId) * curveStrength,
|
||||
rank: getCurveSortRank(edgeClass, priority),
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.rank !== left.rank) {
|
||||
return right.rank - left.rank;
|
||||
}
|
||||
return left.edgeId.localeCompare(right.edgeId);
|
||||
});
|
||||
|
||||
const curves = candidates.slice(0, maxCurves).map(({ rank: _rank, ...curve }) => curve);
|
||||
return {
|
||||
cacheKey,
|
||||
curves,
|
||||
bridgeCurveCount: curves.filter((curve) => curve.edgeClass === "bridge").length,
|
||||
backboneCurveCount: curves.filter((curve) => curve.edgeClass === "backbone").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGraphStructureLayerDiagnostics({
|
||||
gate,
|
||||
cache,
|
||||
minimumCurves,
|
||||
canvasAvailable,
|
||||
lastDrawAt,
|
||||
}: {
|
||||
gate: GraphStructureLayerGate;
|
||||
cache: GraphStructureCurveCache | null;
|
||||
minimumCurves: number;
|
||||
canvasAvailable: boolean;
|
||||
lastDrawAt: number | null;
|
||||
}): GraphStructureLayerDiagnostics {
|
||||
if (!gate.enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: gate.disabledReason,
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!canvasAvailable) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "invalid-layer",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (!cache || cache.curves.length === 0) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "no-eligible-edges",
|
||||
curveCount: 0,
|
||||
bridgeCurveCount: 0,
|
||||
backboneCurveCount: 0,
|
||||
cacheKey: cache?.cacheKey ?? "",
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (cache.curves.length < minimumCurves) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: "cache-empty",
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
curveCount: cache.curves.length,
|
||||
bridgeCurveCount: cache.bridgeCurveCount,
|
||||
backboneCurveCount: cache.backboneCurveCount,
|
||||
cacheKey: cache.cacheKey,
|
||||
lastDrawAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearGraphStructureLayer(canvas: HTMLCanvasElement | null) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
export function drawGraphStructureLayer({
|
||||
sigma,
|
||||
canvas,
|
||||
cache,
|
||||
}: {
|
||||
sigma: Sigma;
|
||||
canvas: HTMLCanvasElement;
|
||||
cache: GraphStructureCurveCache;
|
||||
}) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { width, height } = sigma.getDimensions();
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
|
||||
let drawn = 0;
|
||||
for (const curve of cache.curves) {
|
||||
const sourceData = sigma.getNodeDisplayData(curve.sourceId);
|
||||
const targetData = sigma.getNodeDisplayData(curve.targetId);
|
||||
if (!sourceData || !targetData || sourceData.hidden || targetData.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePoint = sigma.graphToViewport(curve.source);
|
||||
const targetPoint = sigma.graphToViewport(curve.target);
|
||||
if (
|
||||
!Number.isFinite(sourcePoint.x)
|
||||
|| !Number.isFinite(sourcePoint.y)
|
||||
|| !Number.isFinite(targetPoint.x)
|
||||
|| !Number.isFinite(targetPoint.y)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = targetPoint.x - sourcePoint.x;
|
||||
const dy = targetPoint.y - sourcePoint.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (distance <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nx = -dy / distance;
|
||||
const ny = dx / distance;
|
||||
const offset = distance * curve.curvature;
|
||||
const controlX = (sourcePoint.x + targetPoint.x) / 2 + nx * offset;
|
||||
const controlY = (sourcePoint.y + targetPoint.y) / 2 + ny * offset;
|
||||
const layerTheme = GRAPH_THEME.edges.fullGraphStructureLayer;
|
||||
|
||||
context.beginPath();
|
||||
context.strokeStyle = curve.edgeClass === "bridge"
|
||||
? withAlpha(GRAPH_THEME.palette.muted.edgeFocus, layerTheme.bridgeAlpha)
|
||||
: withAlpha(GRAPH_THEME.palette.muted.edgeStructure, layerTheme.backboneAlpha);
|
||||
context.lineWidth = curve.edgeClass === "bridge"
|
||||
? layerTheme.bridgeLineWidth
|
||||
: layerTheme.backboneLineWidth;
|
||||
context.moveTo(sourcePoint.x, sourcePoint.y);
|
||||
context.quadraticCurveTo(controlX, controlY, targetPoint.x, targetPoint.y);
|
||||
context.stroke();
|
||||
drawn += 1;
|
||||
}
|
||||
|
||||
return drawn > 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type GraphZoomTier = "overview" | "structure" | "inspection";
|
||||
export type GraphNodeVisualState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeVisualState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphNodeShapeVariant = "default" | "temporal" | "inferred" | "provenance" | "selected";
|
||||
export type GraphEntityShapeVariant = "entity" | "biomolecule" | "condition" | "compound" | "process" | "community";
|
||||
export type GraphEdgeVariant = "line" | "directional" | "bidirectionalCurve" | "parallelCurve" | "pathSignal";
|
||||
export type GraphArrowVisibilityPolicy = "hidden" | "contextual" | "always";
|
||||
export type GraphLabelVisibilityPolicy = "none" | "priority" | "local" | "always";
|
||||
@@ -53,6 +54,60 @@ export interface GraphTheme {
|
||||
nodeBorder: string;
|
||||
};
|
||||
};
|
||||
ui: {
|
||||
text: {
|
||||
strong: string;
|
||||
body: string;
|
||||
muted: string;
|
||||
subtle: string;
|
||||
inverse: string;
|
||||
};
|
||||
surface: {
|
||||
app: string;
|
||||
stage: string;
|
||||
card: string;
|
||||
cardSubtle: string;
|
||||
cardStrong: string;
|
||||
panel: string;
|
||||
panelBorder: string;
|
||||
divider: string;
|
||||
shadow: string;
|
||||
};
|
||||
scene: {
|
||||
background: string;
|
||||
radialGlow: string;
|
||||
grid: string;
|
||||
gridStrong: string;
|
||||
vignette: string;
|
||||
};
|
||||
control: {
|
||||
defaultBg: string;
|
||||
defaultBorder: string;
|
||||
defaultText: string;
|
||||
hoverBg: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeText: string;
|
||||
primaryBg: string;
|
||||
primaryBorder: string;
|
||||
primaryText: string;
|
||||
disabledText: string;
|
||||
inputBg: string;
|
||||
inputBorder: string;
|
||||
focusRing: string;
|
||||
dangerText: string;
|
||||
};
|
||||
timeline: {
|
||||
background: string;
|
||||
border: string;
|
||||
gridMinor: string;
|
||||
gridMajor: string;
|
||||
text: string;
|
||||
textStrong: string;
|
||||
playhead: string;
|
||||
playheadSoft: string;
|
||||
};
|
||||
};
|
||||
zoomTiers: Record<GraphZoomTier, {
|
||||
maxRatio: number;
|
||||
nodeScale: number;
|
||||
@@ -134,6 +189,16 @@ export interface GraphTheme {
|
||||
badgeKind?: GraphBadgeKind;
|
||||
badgeVisibleFrom: GraphZoomTier;
|
||||
}>;
|
||||
entityShapes: Record<GraphEntityShapeVariant, {
|
||||
label: string;
|
||||
shapeKind: number;
|
||||
aspectRatio: number;
|
||||
fillAlpha: number;
|
||||
shellAlpha: number;
|
||||
coreScale: number;
|
||||
borderBoost: number;
|
||||
minSize: number;
|
||||
}>;
|
||||
selectedRing: {
|
||||
color: string;
|
||||
width: number;
|
||||
@@ -171,6 +236,49 @@ export interface GraphTheme {
|
||||
sizeMultiplier: number;
|
||||
glowAlpha: number;
|
||||
}>;
|
||||
visibility: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, {
|
||||
defaultPriorityThreshold: number;
|
||||
backgroundSampleRate: number;
|
||||
defaultAlpha: number;
|
||||
mutedAlpha: number;
|
||||
inactiveAlpha: number;
|
||||
neighborAlpha: number;
|
||||
sizeMultiplier: number;
|
||||
hideMuted: boolean;
|
||||
}>>;
|
||||
contextCaps: Record<"full" | "grouped" | "focused", Record<GraphZoomTier, number>>;
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeAlpha: number;
|
||||
bridgeCurvePriorityThreshold: number;
|
||||
bridgeCurveStrength: number;
|
||||
backboneMaxSize: number;
|
||||
bridgeMaxSize: number;
|
||||
structureEdgeAlpha: number;
|
||||
inspectionEdgeAlpha: number;
|
||||
};
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off" | "auto" | "always";
|
||||
minimumLiteralEdges: number;
|
||||
minimumCurves: number;
|
||||
maxCurves: number;
|
||||
bridgeAlpha: number;
|
||||
backboneAlpha: number;
|
||||
bridgeLineWidth: number;
|
||||
backboneLineWidth: number;
|
||||
curveStrength: number;
|
||||
};
|
||||
};
|
||||
interaction: {
|
||||
localContextAlpha: number;
|
||||
hoverContextAlpha: number;
|
||||
selectedEdgeAlpha: number;
|
||||
pathEdgeAlpha: number;
|
||||
localContextMaxSize: number;
|
||||
selectedEdgeMaxSize: number;
|
||||
pathEdgeMaxSize: number;
|
||||
pathOverlayAlpha: number;
|
||||
};
|
||||
overlays: {
|
||||
hoverGlowAlpha: number;
|
||||
@@ -302,9 +410,9 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
nodeCoreMix: 0.72,
|
||||
nodeShellAlpha: 0.97,
|
||||
nodeCoreAlpha: 1,
|
||||
edgeBackbone: "rgba(100, 148, 210, 0.38)",
|
||||
edgeStructure: "rgba(88, 140, 200, 0.28)",
|
||||
edgeInspection: "rgba(110, 165, 230, 0.48)",
|
||||
edgeBackbone: "rgba(84, 123, 145, 0.24)",
|
||||
edgeStructure: "rgba(49, 63, 78, 0.08)",
|
||||
edgeInspection: "rgba(76, 102, 128, 0.12)",
|
||||
},
|
||||
accent: {
|
||||
selected: "#F2D288",
|
||||
@@ -317,44 +425,98 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
muted: {
|
||||
fallback: "rgba(96, 112, 136, 0.18)",
|
||||
nodeAlpha: 0.12,
|
||||
edgeOverview: "rgba(82, 100, 124, 0.12)",
|
||||
edgeStructure: "rgba(92, 112, 138, 0.18)",
|
||||
edgeInspection: "rgba(124, 148, 176, 0.26)",
|
||||
edgeFocus: "rgba(160, 186, 218, 0.42)",
|
||||
edgeOverview: "rgba(32, 45, 55, 0.035)",
|
||||
edgeStructure: "rgba(42, 58, 72, 0.055)",
|
||||
edgeInspection: "rgba(62, 84, 104, 0.075)",
|
||||
edgeFocus: "rgba(132, 178, 202, 0.26)",
|
||||
},
|
||||
background: {
|
||||
canvas: "#07101A",
|
||||
shell: "rgba(8, 15, 26, 0.8)",
|
||||
shellBorder: "rgba(118, 162, 207, 0.14)",
|
||||
shellGlow: "rgba(48, 88, 140, 0.14)",
|
||||
grid: "rgba(92, 126, 170, 0.034)",
|
||||
vignette: "rgba(2, 5, 11, 0.84)",
|
||||
nodeBorder: "#0C1522",
|
||||
canvas: "#0A0D11",
|
||||
shell: "rgba(17, 21, 27, 0.82)",
|
||||
shellBorder: "rgba(170, 184, 205, 0.14)",
|
||||
shellGlow: "rgba(0, 0, 0, 0.28)",
|
||||
grid: "rgba(170, 184, 205, 0.026)",
|
||||
vignette: "rgba(3, 4, 7, 0.76)",
|
||||
nodeBorder: "#0B0F15",
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
text: {
|
||||
strong: "#F3F0E8",
|
||||
body: "#D5D9DD",
|
||||
muted: "#9AA3AE",
|
||||
subtle: "#6F7A86",
|
||||
inverse: "#0B0D10",
|
||||
},
|
||||
surface: {
|
||||
app: "#08090B",
|
||||
stage: "#0B0E12",
|
||||
card: "linear-gradient(180deg, rgba(28, 31, 36, 0.88), rgba(16, 18, 23, 0.78))",
|
||||
cardSubtle: "linear-gradient(180deg, rgba(23, 26, 31, 0.72), rgba(13, 15, 19, 0.64))",
|
||||
cardStrong: "linear-gradient(180deg, rgba(34, 37, 43, 0.94), rgba(18, 21, 26, 0.9))",
|
||||
panel: "linear-gradient(180deg, rgba(21, 24, 30, 0.92), rgba(12, 14, 18, 0.9))",
|
||||
panelBorder: "rgba(211, 205, 190, 0.13)",
|
||||
divider: "rgba(211, 205, 190, 0.1)",
|
||||
shadow: "0 22px 60px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.045)",
|
||||
},
|
||||
scene: {
|
||||
background: "linear-gradient(180deg, #0B0E12 0%, #07080B 100%)",
|
||||
radialGlow: "radial-gradient(circle at 50% 18%, rgba(88, 224, 204, 0.07), transparent 30%), radial-gradient(circle at 78% 0%, rgba(217, 168, 92, 0.055), transparent 26%)",
|
||||
grid: "rgba(210, 206, 196, 0.024)",
|
||||
gridStrong: "rgba(210, 206, 196, 0.052)",
|
||||
vignette: "radial-gradient(ellipse at center, transparent 42%, rgba(2, 3, 5, 0.82) 100%)",
|
||||
},
|
||||
control: {
|
||||
defaultBg: "rgba(255, 255, 255, 0.035)",
|
||||
defaultBorder: "rgba(211, 205, 190, 0.11)",
|
||||
defaultText: "#D7D1C4",
|
||||
hoverBg: "rgba(255, 255, 255, 0.065)",
|
||||
activeBg: "linear-gradient(180deg, rgba(74, 181, 166, 0.24), rgba(38, 118, 116, 0.18))",
|
||||
activeBorder: "rgba(98, 226, 205, 0.42)",
|
||||
activeText: "#E8FFFA",
|
||||
primaryBg: "linear-gradient(180deg, rgba(55, 145, 132, 0.42), rgba(24, 86, 88, 0.28))",
|
||||
primaryBorder: "rgba(99, 228, 206, 0.34)",
|
||||
primaryText: "#F2FFFB",
|
||||
disabledText: "rgba(154, 163, 174, 0.42)",
|
||||
inputBg: "rgba(5, 7, 10, 0.52)",
|
||||
inputBorder: "rgba(211, 205, 190, 0.13)",
|
||||
focusRing: "rgba(98, 226, 205, 0.16)",
|
||||
dangerText: "#FF9A8D",
|
||||
},
|
||||
timeline: {
|
||||
background: "linear-gradient(180deg, rgba(14, 18, 24, 0.86), rgba(8, 11, 15, 0.92))",
|
||||
border: "rgba(170, 184, 205, 0.12)",
|
||||
gridMinor: "rgba(170, 184, 205, 0.04)",
|
||||
gridMajor: "rgba(170, 184, 205, 0.09)",
|
||||
text: "#7A92AE",
|
||||
textStrong: "#A5B7CD",
|
||||
playhead: "#8FE7FF",
|
||||
playheadSoft: "rgba(143, 231, 255, 0.12)",
|
||||
},
|
||||
},
|
||||
zoomTiers: {
|
||||
overview: {
|
||||
maxRatio: Number.POSITIVE_INFINITY,
|
||||
nodeScale: 0.72,
|
||||
labelThreshold: 0.995,
|
||||
labelBudget: 4,
|
||||
labelThreshold: 0.998,
|
||||
labelBudget: 2,
|
||||
edgePriorityThreshold: 0.72,
|
||||
arrowPriorityThreshold: Number.POSITIVE_INFINITY,
|
||||
edgeSizeScale: 0.62,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
structure: {
|
||||
maxRatio: 1.2,
|
||||
nodeScale: 0.94,
|
||||
labelThreshold: 0.93,
|
||||
labelBudget: 18,
|
||||
labelThreshold: 0.95,
|
||||
labelBudget: 12,
|
||||
edgePriorityThreshold: 0.4,
|
||||
arrowPriorityThreshold: 0.75,
|
||||
edgeSizeScale: 0.92,
|
||||
showBadges: false,
|
||||
showCurves: false,
|
||||
showCurves: true,
|
||||
showContextualArrows: false,
|
||||
},
|
||||
inspection: {
|
||||
@@ -428,11 +590,11 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
inspection: { base: 1.2, emphasis: 1.7, muted: 0.6 },
|
||||
},
|
||||
states: {
|
||||
default: { color: "base", sizeMultiplier: 0.72, minSize: 0.68, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.1, minSize: 10.8, forceLabel: true, zIndex: 4, borderBoost: 0.18 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.4, forceLabel: true, zIndex: 3, borderBoost: 0.18 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.78, minSize: 4.2, forceLabel: false, zIndex: 2, borderBoost: -0.12 },
|
||||
path: { color: "path", sizeMultiplier: 0.97, minSize: 5.8, forceLabel: true, zIndex: 2, borderBoost: 0.06 },
|
||||
default: { color: "base", sizeMultiplier: 0.7, minSize: 0.64, forceLabel: false, zIndex: 0, borderBoost: -0.46 },
|
||||
hovered: { color: "hovered", sizeMultiplier: 1.08, minSize: 10.4, forceLabel: true, zIndex: 4, borderBoost: 0.2 },
|
||||
selected: { color: "selected", sizeMultiplier: 1.02, minSize: 9.2, forceLabel: true, zIndex: 3, borderBoost: 0.22 },
|
||||
neighbor: { color: "base", sizeMultiplier: 0.76, minSize: 4, forceLabel: false, zIndex: 2, borderBoost: -0.08 },
|
||||
path: { color: "path", sizeMultiplier: 0.96, minSize: 5.6, forceLabel: true, zIndex: 2, borderBoost: 0.08 },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
muted: { color: "muted", sizeMultiplier: 0.52, minSize: 0.58, forceLabel: false, zIndex: 0, borderBoost: -0.42 },
|
||||
},
|
||||
@@ -443,6 +605,68 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
provenance: { sizeMultiplier: 1.03, borderBoost: 0.14, haloBoost: 0.12, badgeKind: "provenance", badgeVisibleFrom: "inspection" },
|
||||
selected: { sizeMultiplier: 1.06, borderBoost: 0.22, haloBoost: 0.16, badgeVisibleFrom: "overview" },
|
||||
},
|
||||
entityShapes: {
|
||||
entity: {
|
||||
label: "Entity",
|
||||
shapeKind: 0,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0,
|
||||
borderBoost: 0.08,
|
||||
minSize: 0,
|
||||
},
|
||||
biomolecule: {
|
||||
label: "Biomolecule",
|
||||
shapeKind: 1,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.9,
|
||||
shellAlpha: 0.16,
|
||||
coreScale: 0.18,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.2,
|
||||
},
|
||||
condition: {
|
||||
label: "Condition",
|
||||
shapeKind: 2,
|
||||
aspectRatio: 1.04,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.16,
|
||||
borderBoost: 0.18,
|
||||
minSize: 1.6,
|
||||
},
|
||||
compound: {
|
||||
label: "Compound",
|
||||
shapeKind: 3,
|
||||
aspectRatio: 1.48,
|
||||
fillAlpha: 0.88,
|
||||
shellAlpha: 0.15,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.14,
|
||||
minSize: 1.4,
|
||||
},
|
||||
process: {
|
||||
label: "Process",
|
||||
shapeKind: 4,
|
||||
aspectRatio: 1.1,
|
||||
fillAlpha: 0.87,
|
||||
shellAlpha: 0.14,
|
||||
coreScale: 0.14,
|
||||
borderBoost: 0.16,
|
||||
minSize: 1.4,
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
shapeKind: 5,
|
||||
aspectRatio: 1,
|
||||
fillAlpha: 0.68,
|
||||
shellAlpha: 0.28,
|
||||
coreScale: 0.78,
|
||||
borderBoost: 0.34,
|
||||
minSize: 2,
|
||||
},
|
||||
},
|
||||
selectedRing: {
|
||||
color: "#E7C57C",
|
||||
width: 1.9,
|
||||
@@ -467,14 +691,14 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
},
|
||||
edges: {
|
||||
states: {
|
||||
default: { color: "structure", sizeMultiplier: 0.96, minSize: 0.9, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 1.0, minSize: 1.0, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.6, minSize: 2.2, zIndex: 3, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 1.1, minSize: 1.2, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.7, minSize: 2.4, zIndex: 4, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.6, minSize: 0.5, zIndex: 0, forceArrow: false, hide: false },
|
||||
default: { color: "structure", sizeMultiplier: 0.48, minSize: 0.2, zIndex: 0, forceArrow: false, hide: false },
|
||||
backbone: { color: "backbone", sizeMultiplier: 0.62, minSize: 0.36, zIndex: 1, forceArrow: false, hide: false },
|
||||
hovered: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
selected: { color: "hover", sizeMultiplier: 1.42, minSize: 1.85, zIndex: 5, forceArrow: true, hide: false },
|
||||
neighbor: { color: "focus", sizeMultiplier: 0.96, minSize: 0.86, zIndex: 1, forceArrow: false, hide: false },
|
||||
path: { color: "path", sizeMultiplier: 1.82, minSize: 2.55, zIndex: 6, forceArrow: true, hide: false },
|
||||
inactive: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
muted: { color: "muted", sizeMultiplier: 0.24, minSize: 0.18, zIndex: 0, forceArrow: false, hide: false },
|
||||
},
|
||||
variants: {
|
||||
line: { baseType: "line", arrowPolicy: "hidden", curveStrength: 0, sizeMultiplier: 1, glowAlpha: 0 },
|
||||
@@ -483,6 +707,155 @@ export const GRAPH_THEME: GraphTheme = {
|
||||
parallelCurve: { baseType: "line", arrowPolicy: "contextual", curveStrength: 0.24, sizeMultiplier: 1.1, glowAlpha: 0.12 },
|
||||
pathSignal: { baseType: "arrow", arrowPolicy: "always", curveStrength: 0.16, sizeMultiplier: 1.18, glowAlpha: 0.2 },
|
||||
},
|
||||
visibility: {
|
||||
full: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.96,
|
||||
backgroundSampleRate: 0.035,
|
||||
defaultAlpha: 0.026,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.26,
|
||||
sizeMultiplier: 0.5,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.82,
|
||||
backgroundSampleRate: 0.16,
|
||||
defaultAlpha: 0.04,
|
||||
mutedAlpha: 0.014,
|
||||
inactiveAlpha: 0.012,
|
||||
neighborAlpha: 0.32,
|
||||
sizeMultiplier: 0.62,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.28,
|
||||
defaultAlpha: 0.052,
|
||||
mutedAlpha: 0.012,
|
||||
inactiveAlpha: 0.01,
|
||||
neighborAlpha: 0.38,
|
||||
sizeMultiplier: 0.64,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
grouped: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.42,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.18,
|
||||
mutedAlpha: 0.06,
|
||||
inactiveAlpha: 0.04,
|
||||
neighborAlpha: 0.36,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: false,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.34,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.2,
|
||||
mutedAlpha: 0.07,
|
||||
inactiveAlpha: 0.05,
|
||||
neighborAlpha: 0.42,
|
||||
sizeMultiplier: 0.78,
|
||||
hideMuted: false,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.28,
|
||||
backgroundSampleRate: 1,
|
||||
defaultAlpha: 0.22,
|
||||
mutedAlpha: 0.08,
|
||||
inactiveAlpha: 0.06,
|
||||
neighborAlpha: 0.46,
|
||||
sizeMultiplier: 0.82,
|
||||
hideMuted: false,
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
overview: {
|
||||
defaultPriorityThreshold: 0.72,
|
||||
backgroundSampleRate: 0.7,
|
||||
defaultAlpha: 0.1,
|
||||
mutedAlpha: 0.03,
|
||||
inactiveAlpha: 0.02,
|
||||
neighborAlpha: 0.06,
|
||||
sizeMultiplier: 0.72,
|
||||
hideMuted: true,
|
||||
},
|
||||
structure: {
|
||||
defaultPriorityThreshold: 0.6,
|
||||
backgroundSampleRate: 0.8,
|
||||
defaultAlpha: 0.12,
|
||||
mutedAlpha: 0.035,
|
||||
inactiveAlpha: 0.025,
|
||||
neighborAlpha: 0.08,
|
||||
sizeMultiplier: 0.8,
|
||||
hideMuted: true,
|
||||
},
|
||||
inspection: {
|
||||
defaultPriorityThreshold: 0.52,
|
||||
backgroundSampleRate: 0.9,
|
||||
defaultAlpha: 0.14,
|
||||
mutedAlpha: 0.04,
|
||||
inactiveAlpha: 0.03,
|
||||
neighborAlpha: 0.1,
|
||||
sizeMultiplier: 0.88,
|
||||
hideMuted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
contextCaps: {
|
||||
full: {
|
||||
overview: 0,
|
||||
structure: 12,
|
||||
inspection: 24,
|
||||
},
|
||||
grouped: {
|
||||
overview: 6,
|
||||
structure: 8,
|
||||
inspection: 10,
|
||||
},
|
||||
focused: {
|
||||
overview: 24,
|
||||
structure: 36,
|
||||
inspection: 48,
|
||||
},
|
||||
},
|
||||
fullGraphStructure: {
|
||||
ambientBackboneAlpha: 0.12,
|
||||
backboneAlpha: 0.08,
|
||||
bridgeAlpha: 0.14,
|
||||
bridgeCurvePriorityThreshold: 0.78,
|
||||
bridgeCurveStrength: 0.1,
|
||||
backboneMaxSize: 0.5,
|
||||
bridgeMaxSize: 0.7,
|
||||
structureEdgeAlpha: 0.12,
|
||||
inspectionEdgeAlpha: 0.1,
|
||||
},
|
||||
// Staged rollout — set mode to "auto" to enable cross-community curve rendering.
|
||||
// Currently "off" so the canvas overlay layer is inactive in production.
|
||||
fullGraphStructureLayer: {
|
||||
mode: "off",
|
||||
minimumLiteralEdges: 24,
|
||||
minimumCurves: 8,
|
||||
maxCurves: 64,
|
||||
bridgeAlpha: 0.16,
|
||||
backboneAlpha: 0.1,
|
||||
bridgeLineWidth: 0.9,
|
||||
backboneLineWidth: 0.62,
|
||||
curveStrength: 0.12,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
localContextAlpha: 0.32,
|
||||
hoverContextAlpha: 0.32,
|
||||
selectedEdgeAlpha: 0.6,
|
||||
pathEdgeAlpha: 0.76,
|
||||
localContextMaxSize: 0.6,
|
||||
selectedEdgeMaxSize: 1.0,
|
||||
pathEdgeMaxSize: 1.4,
|
||||
pathOverlayAlpha: 0.16,
|
||||
},
|
||||
overlays: {
|
||||
hoverGlowAlpha: 0.18,
|
||||
@@ -629,7 +1002,7 @@ export function withAlpha(color: string | undefined, alpha: number): string {
|
||||
}
|
||||
|
||||
if (color.startsWith("rgba(")) {
|
||||
return color.replace(/rgba\(([^)]+),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
return color.replace(/rgba\((.*?),\s*[\d.]+\)/, `rgba($1, ${alpha})`);
|
||||
}
|
||||
|
||||
if (color.startsWith("rgb(")) {
|
||||
|
||||
@@ -5,13 +5,14 @@ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/gra
|
||||
import type {
|
||||
GraphAnalyticsSnapshot,
|
||||
GraphCameraState,
|
||||
GraphDistanceVisualState,
|
||||
GraphDisplayMeta,
|
||||
GraphDisplayStateSnapshot,
|
||||
GraphDiagnosticsSnapshot,
|
||||
GraphEffectsState,
|
||||
GraphInteractionState,
|
||||
GraphLayoutSource,
|
||||
GraphLayoutStatus,
|
||||
GraphRuntimeDiagnosticsSnapshot,
|
||||
GraphTemporalState,
|
||||
GraphViewMode,
|
||||
} from "./types";
|
||||
@@ -35,7 +36,7 @@ export interface GraphSceneEventMap {
|
||||
onEdgeSelect?: (edgeId: string) => void;
|
||||
onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
|
||||
onCameraStateChange?: (cameraState: GraphCameraState) => void;
|
||||
onDiagnosticsChange?: (effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => void;
|
||||
onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
|
||||
onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
|
||||
onRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
|
||||
}
|
||||
@@ -51,6 +52,7 @@ export interface GraphSceneProps extends GraphSceneEventMap {
|
||||
selectedEdgeId: string;
|
||||
activePath?: string[];
|
||||
activePathEdgeIds?: string[];
|
||||
distanceVisualState?: GraphDistanceVisualState;
|
||||
effectsState: GraphEffectsState;
|
||||
temporalState?: GraphTemporalState | null;
|
||||
isLayoutRunning: boolean;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { NodeDisplayData, RenderParams } from "sigma/types";
|
||||
import { floatColor } from "sigma/utils";
|
||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||
|
||||
import { GRAPH_THEME, withAlpha } from "./graphTheme";
|
||||
import { GRAPH_THEME, type GraphEntityShapeVariant, withAlpha } from "./graphTheme";
|
||||
|
||||
type SemanticaNodeDrawData = {
|
||||
x: number;
|
||||
@@ -16,39 +16,106 @@ type SemanticaNodeDrawData = {
|
||||
shellColor?: string;
|
||||
coreScale?: number;
|
||||
borderColor?: string;
|
||||
borderSize?: number;
|
||||
ringColor?: string;
|
||||
ringSize?: number;
|
||||
entityShape?: GraphEntityShapeVariant;
|
||||
entityShapeKind?: number;
|
||||
entityAspectRatio?: number;
|
||||
nodeType?: string;
|
||||
};
|
||||
|
||||
const MINERAL_DISC_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
const ENTITY_TOKEN_UNIFORMS = ["u_sizeRatio", "u_correctionRatio", "u_matrix"] as const;
|
||||
|
||||
const MINERAL_DISC_FRAGMENT_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_FRAGMENT_SHADER = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
const vec4 transparent = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
float discMetric(vec2 point) {
|
||||
return length(point);
|
||||
float hexMetric(vec2 point) {
|
||||
vec2 q = abs(point);
|
||||
return max(q.y, q.x * 0.8660254 + q.y * 0.5);
|
||||
}
|
||||
|
||||
vec2 rotate45(vec2 point) {
|
||||
const float invSqrt2 = 0.70710678;
|
||||
return vec2(
|
||||
(point.x - point.y) * invSqrt2,
|
||||
(point.x + point.y) * invSqrt2
|
||||
);
|
||||
}
|
||||
|
||||
float roundedBoxDistance(vec2 point, vec2 halfSize, float radius) {
|
||||
vec2 q = abs(point) - halfSize + vec2(radius);
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
|
||||
}
|
||||
|
||||
float capsuleDistance(vec2 point) {
|
||||
vec2 q = vec2(max(abs(point.x) - 0.44, 0.0), point.y);
|
||||
return length(q) - 0.56;
|
||||
}
|
||||
|
||||
float shapeDistance(vec2 point, float shapeKind) {
|
||||
if (shapeKind < 0.5) {
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return hexMetric(point) - 0.92;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return roundedBoxDistance(rotate45(point), vec2(0.58, 0.58), 0.18);
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return capsuleDistance(point);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return roundedBoxDistance(point, vec2(0.78, 0.78), 0.24);
|
||||
}
|
||||
return length(point) - 1.0;
|
||||
}
|
||||
|
||||
float glyphDistance(vec2 point, float shapeKind, float scale) {
|
||||
vec2 scaled = point / max(scale, 0.08);
|
||||
if (shapeKind < 0.5) {
|
||||
return 1.0;
|
||||
}
|
||||
if (shapeKind < 1.5) {
|
||||
return abs(hexMetric(scaled) - 0.74) - 0.055;
|
||||
}
|
||||
if (shapeKind < 2.5) {
|
||||
return abs(abs(scaled.x) + abs(scaled.y) - 0.78) - 0.045;
|
||||
}
|
||||
if (shapeKind < 3.5) {
|
||||
return roundedBoxDistance(scaled, vec2(0.56, 0.07), 0.07);
|
||||
}
|
||||
if (shapeKind < 4.5) {
|
||||
return abs(roundedBoxDistance(scaled, vec2(0.48, 0.48), 0.18)) - 0.045;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 unit = v_diffVector / max(v_radius, 0.0001);
|
||||
float metric = discMetric(unit);
|
||||
vec2 unit = vec2(
|
||||
v_diffVector.x / max(v_radius * v_aspectRatio, 0.0001),
|
||||
v_diffVector.y / max(v_radius, 0.0001)
|
||||
);
|
||||
float aa = (2.4 * u_correctionRatio) / max(v_radius, 1.0);
|
||||
float alpha = 1.0 - smoothstep(1.0 - aa, 1.0 + aa, metric);
|
||||
float distance = shapeDistance(unit, v_shapeKind);
|
||||
float alpha = 1.0 - smoothstep(-aa, aa, distance);
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
if (alpha <= 0.0) {
|
||||
@@ -63,52 +130,63 @@ void main(void) {
|
||||
return;
|
||||
}
|
||||
|
||||
float ringNorm = clamp(v_ringSize / max(v_radius, 1.0), 0.0, 0.45);
|
||||
float ringStart = max(0.0, 1.0 - ringNorm);
|
||||
float coreEdge = clamp(v_coreScale, 0.06, 0.78);
|
||||
float coreBlend = 1.0 - smoothstep(max(coreEdge - 0.14, 0.0), coreEdge, metric);
|
||||
float bodyLight = 1.0 - smoothstep(0.0, 0.82, metric);
|
||||
vec4 color = mix(v_shellColor, v_coreColor, coreBlend);
|
||||
color.rgb += vec3(0.022) * pow(bodyLight, 1.45);
|
||||
float outlineNorm = clamp(v_outlineSize / max(v_radius, 1.0), 0.035, 0.28);
|
||||
float outlineBlend = 1.0 - smoothstep(-outlineNorm - aa, -outlineNorm + aa, distance);
|
||||
float isOutline = 1.0 - outlineBlend;
|
||||
float topLight = clamp((-unit.y + 0.85) * 0.5, 0.0, 1.0);
|
||||
vec4 color = v_bodyColor;
|
||||
color.rgb += vec3(0.014) * pow(topLight, 2.2);
|
||||
|
||||
if (ringNorm > 0.0 && metric >= ringStart) {
|
||||
color = v_ringColor;
|
||||
if (isOutline > 0.0) {
|
||||
color = mix(color, v_outlineColor, isOutline);
|
||||
}
|
||||
|
||||
float glyphVisible = step(7.25, v_radius) * step(0.13, v_glyphScale) * step(0.5, v_shapeKind) * (1.0 - step(4.5, v_shapeKind));
|
||||
float glyph = (1.0 - smoothstep(-aa * 1.4, aa * 1.4, glyphDistance(unit, v_shapeKind, clamp(v_glyphScale, 0.16, 0.52)))) * glyphVisible;
|
||||
if (glyph > 0.0 && distance < -outlineNorm) {
|
||||
color = mix(color, v_glyphColor, glyph * 0.38);
|
||||
}
|
||||
|
||||
color.a *= alpha;
|
||||
gl_FragColor = color;
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const MINERAL_DISC_VERTEX_SHADER = /* glsl */ `
|
||||
const ENTITY_TOKEN_VERTEX_SHADER = /* glsl */ `
|
||||
attribute vec4 a_id;
|
||||
attribute vec2 a_position;
|
||||
attribute float a_size;
|
||||
attribute float a_angle;
|
||||
attribute vec4 a_coreColor;
|
||||
attribute vec4 a_shellColor;
|
||||
attribute vec4 a_ringColor;
|
||||
attribute float a_ringSize;
|
||||
attribute float a_coreScale;
|
||||
attribute vec4 a_bodyColor;
|
||||
attribute vec4 a_glyphColor;
|
||||
attribute vec4 a_outlineColor;
|
||||
attribute float a_outlineSize;
|
||||
attribute float a_glyphScale;
|
||||
attribute float a_shapeKind;
|
||||
attribute float a_aspectRatio;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
uniform float u_sizeRatio;
|
||||
uniform float u_correctionRatio;
|
||||
|
||||
varying vec4 v_coreColor;
|
||||
varying vec4 v_shellColor;
|
||||
varying vec4 v_ringColor;
|
||||
varying vec4 v_bodyColor;
|
||||
varying vec4 v_glyphColor;
|
||||
varying vec4 v_outlineColor;
|
||||
varying vec4 v_color;
|
||||
varying vec2 v_diffVector;
|
||||
varying float v_radius;
|
||||
varying float v_ringSize;
|
||||
varying float v_coreScale;
|
||||
varying float v_outlineSize;
|
||||
varying float v_glyphScale;
|
||||
varying float v_shapeKind;
|
||||
varying float v_aspectRatio;
|
||||
|
||||
const float bias = 255.0 / 254.0;
|
||||
|
||||
void main() {
|
||||
float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
|
||||
vec2 diffVector = size * vec2(cos(a_angle), sin(a_angle));
|
||||
float aspect = max(a_aspectRatio, 1.0);
|
||||
vec2 diffVector = size * vec2(cos(a_angle) * aspect, sin(a_angle));
|
||||
vec2 position = a_position + diffVector;
|
||||
|
||||
gl_Position = vec4(
|
||||
@@ -119,22 +197,24 @@ void main() {
|
||||
|
||||
v_diffVector = diffVector;
|
||||
v_radius = size / 2.0;
|
||||
v_ringSize = a_ringSize;
|
||||
v_coreScale = a_coreScale;
|
||||
v_outlineSize = a_outlineSize;
|
||||
v_glyphScale = a_glyphScale;
|
||||
v_shapeKind = a_shapeKind;
|
||||
v_aspectRatio = aspect;
|
||||
|
||||
#ifdef PICKING_MODE
|
||||
v_color = a_id;
|
||||
#else
|
||||
v_coreColor = a_coreColor;
|
||||
v_shellColor = a_shellColor;
|
||||
v_ringColor = a_ringColor;
|
||||
v_bodyColor = a_bodyColor;
|
||||
v_glyphColor = a_glyphColor;
|
||||
v_outlineColor = a_outlineColor;
|
||||
#endif
|
||||
|
||||
v_color.a *= bias;
|
||||
}
|
||||
`;
|
||||
|
||||
class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[number]> {
|
||||
class EntityTokenNodeProgram extends NodeProgram<(typeof ENTITY_TOKEN_UNIFORMS)[number]> {
|
||||
static readonly ANGLE_1 = 0;
|
||||
static readonly ANGLE_2 = (2 * Math.PI) / 3;
|
||||
static readonly ANGLE_3 = (4 * Math.PI) / 3;
|
||||
@@ -146,47 +226,52 @@ class MineralDiscNodeProgram extends NodeProgram<(typeof MINERAL_DISC_UNIFORMS)[
|
||||
getDefinition() {
|
||||
return {
|
||||
VERTICES: 3,
|
||||
VERTEX_SHADER_SOURCE: MINERAL_DISC_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: MINERAL_DISC_FRAGMENT_SHADER,
|
||||
VERTEX_SHADER_SOURCE: ENTITY_TOKEN_VERTEX_SHADER,
|
||||
FRAGMENT_SHADER_SOURCE: ENTITY_TOKEN_FRAGMENT_SHADER,
|
||||
METHOD: WebGLRenderingContext.TRIANGLES,
|
||||
UNIFORMS: MINERAL_DISC_UNIFORMS,
|
||||
UNIFORMS: ENTITY_TOKEN_UNIFORMS,
|
||||
ATTRIBUTES: [
|
||||
{ name: "a_position", size: 2, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_size", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_shellColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_ringSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_coreScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_bodyColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_glyphColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineColor", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
{ name: "a_outlineSize", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_glyphScale", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_shapeKind", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_aspectRatio", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
{ name: "a_id", size: 4, type: WebGLRenderingContext.UNSIGNED_BYTE, normalized: true },
|
||||
],
|
||||
CONSTANT_ATTRIBUTES: [
|
||||
{ name: "a_angle", size: 1, type: WebGLRenderingContext.FLOAT },
|
||||
],
|
||||
CONSTANT_DATA: [
|
||||
[MineralDiscNodeProgram.ANGLE_1],
|
||||
[MineralDiscNodeProgram.ANGLE_2],
|
||||
[MineralDiscNodeProgram.ANGLE_3],
|
||||
[EntityTokenNodeProgram.ANGLE_1],
|
||||
[EntityTokenNodeProgram.ANGLE_2],
|
||||
[EntityTokenNodeProgram.ANGLE_3],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & SemanticaNodeDrawData): void {
|
||||
const array = this.array;
|
||||
const ringColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
const outlineColor = resolveAccentBorderColor(data.ringSize, data.ringColor, data.borderColor, GRAPH_THEME.nodes.selectedRing.color);
|
||||
const outlineSize = Math.max(data.ringSize || 0, data.borderSize || 0.7);
|
||||
|
||||
array[startIndex++] = data.x;
|
||||
array[startIndex++] = data.y;
|
||||
array[startIndex++] = data.size;
|
||||
array[startIndex++] = floatColor(data.color || GRAPH_THEME.palette.overview.nodeCore);
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, GRAPH_THEME.palette.overview.nodeShellAlpha));
|
||||
array[startIndex++] = floatColor(ringColor);
|
||||
array[startIndex++] = data.ringSize || 0;
|
||||
array[startIndex++] = floatColor(data.shellColor || withAlpha(GRAPH_THEME.palette.overview.nodeBase, 0.58));
|
||||
array[startIndex++] = floatColor(outlineColor);
|
||||
array[startIndex++] = outlineSize;
|
||||
array[startIndex++] = data.coreScale ?? 0.22;
|
||||
array[startIndex++] = data.entityShapeKind ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].shapeKind;
|
||||
array[startIndex++] = data.entityAspectRatio ?? GRAPH_THEME.nodes.entityShapes[data.entityShape || "entity"].aspectRatio;
|
||||
array[startIndex++] = nodeIndex;
|
||||
}
|
||||
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof MINERAL_DISC_UNIFORMS)[number]>): void {
|
||||
setUniforms(params: RenderParams, { gl, uniformLocations }: ProgramInfo<(typeof ENTITY_TOKEN_UNIFORMS)[number]>): void {
|
||||
gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
|
||||
gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
|
||||
gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
|
||||
@@ -326,7 +411,7 @@ export const drawSemanticaNodeHover: NodeHoverDrawingFunction = (context, rawDat
|
||||
|
||||
export const SEMANTICA_NODE_PROGRAM_CLASSES = {
|
||||
...DEFAULT_NODE_PROGRAM_CLASSES,
|
||||
circle: MineralDiscNodeProgram,
|
||||
circle: EntityTokenNodeProgram,
|
||||
};
|
||||
|
||||
export const SEMANTICA_EDGE_PROGRAM_CLASSES = {
|
||||
|
||||
@@ -12,7 +12,45 @@ export type GraphLoadPhase =
|
||||
export type GraphLoadProgressKind = "determinate" | "indeterminate";
|
||||
export type GraphNodeInteractionState = "default" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphEdgeInteractionState = "default" | "backbone" | "hovered" | "selected" | "neighbor" | "path" | "inactive" | "muted";
|
||||
export type GraphFullEdgeClass = "hidden" | "backbone" | "bridge" | "local-context" | "selected" | "path" | "muted";
|
||||
export type GraphSelectedNodeKind = "none" | "base" | "grouped" | "unavailable";
|
||||
export type GraphDistanceVisualMode = "off" | "ego" | "heatmap" | "structural" | "semantic";
|
||||
export type GraphDistanceVisualStatus = "idle" | "loading" | "ready" | "unavailable" | "error";
|
||||
|
||||
export interface GraphDistanceBucketCounts {
|
||||
anchor: number;
|
||||
oneHop: number;
|
||||
twoHop: number;
|
||||
threeHopPlus: number;
|
||||
outside: number;
|
||||
}
|
||||
|
||||
export type GraphHeatmapSaturationMode = "normal" | "sampled";
|
||||
|
||||
export interface GraphHeatmapRenderSnapshot {
|
||||
visibleNodeIds: string[];
|
||||
ringCounts: GraphDistanceBucketCounts;
|
||||
renderedRingCounts: GraphDistanceBucketCounts;
|
||||
saturationMode: GraphHeatmapSaturationMode;
|
||||
}
|
||||
|
||||
export interface GraphDistanceVisualState {
|
||||
mode: GraphDistanceVisualMode;
|
||||
anchorNodeId: string | null;
|
||||
anchorLabel?: string | null;
|
||||
maxHops: number;
|
||||
structuralDistances: Record<string, number>;
|
||||
semanticScores: Record<string, number>;
|
||||
distanceCounts?: GraphDistanceBucketCounts;
|
||||
outsideCount?: number;
|
||||
heatmapVisibleNodeIds?: string[];
|
||||
heatmapRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapRenderedRingCounts?: GraphDistanceBucketCounts;
|
||||
heatmapSaturationMode?: GraphHeatmapSaturationMode;
|
||||
semanticNeighborCount?: number;
|
||||
status: GraphDistanceVisualStatus;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GraphCameraState {
|
||||
x: number;
|
||||
@@ -92,11 +130,51 @@ export interface GraphEffectAvailability {
|
||||
segmentCap?: number;
|
||||
}
|
||||
|
||||
export type GraphFullEdgeClassCounts = Record<GraphFullEdgeClass, number>;
|
||||
|
||||
export interface GraphFullEdgeClassDiagnostics {
|
||||
mode: GraphViewMode;
|
||||
zoomTier: GraphInteractionState["zoomTier"];
|
||||
totalEdges: number;
|
||||
visibleEdges: number;
|
||||
counts: GraphFullEdgeClassCounts;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type GraphStructureLayerDisabledReason =
|
||||
| "non-full-mode"
|
||||
| "layout-running"
|
||||
| "enough-literal-edges"
|
||||
| "no-eligible-edges"
|
||||
| "invalid-layer"
|
||||
| "cache-empty"
|
||||
| "disabled";
|
||||
|
||||
export interface GraphStructureLayerDiagnostics {
|
||||
enabled: boolean;
|
||||
disabledReason: GraphStructureLayerDisabledReason | null;
|
||||
curveCount: number;
|
||||
bridgeCurveCount: number;
|
||||
backboneCurveCount: number;
|
||||
cacheKey: string;
|
||||
lastDrawAt: number | null;
|
||||
}
|
||||
|
||||
export interface GraphRuntimeDiagnosticsSnapshot {
|
||||
effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"];
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
}
|
||||
|
||||
export interface GraphDiagnosticsSnapshot {
|
||||
interactionState: GraphInteractionState;
|
||||
activePluginIds: string[];
|
||||
openPanelIds: string[];
|
||||
effectsState: GraphEffectsState;
|
||||
edgeClasses?: GraphFullEdgeClassDiagnostics;
|
||||
structureLayer?: GraphStructureLayerDiagnostics;
|
||||
distanceVisual?: GraphDistanceVisualState;
|
||||
effectAvailability: {
|
||||
pathPulse: GraphEffectAvailability;
|
||||
pathFlow: GraphEffectAvailability;
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
withAlpha,
|
||||
type GraphBadgeKind,
|
||||
type GraphEdgeVariant,
|
||||
type GraphEntityShapeVariant,
|
||||
type GraphLabelVisibilityPolicy,
|
||||
type GraphNodeShapeVariant,
|
||||
} from "./graphTheme";
|
||||
import { classifyEntityShape } from "./graphEntityShape";
|
||||
import { createGraphLoadProgress } from "./graphLoading";
|
||||
import type { GraphLoadProgress, GraphLoadSummary } from "./types";
|
||||
|
||||
@@ -196,6 +198,15 @@ function getProvenanceCount(properties: Record<string, unknown>): number {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntityShape(attributes: NodeAttributes, semanticGroup: string): GraphEntityShapeVariant {
|
||||
return classifyEntityShape(
|
||||
attributes.nodeType,
|
||||
semanticGroup,
|
||||
attributes.content,
|
||||
attributes.properties as Record<string, unknown> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNodeVariantMetadata(
|
||||
baseColor: string,
|
||||
sizeRatio: number,
|
||||
@@ -548,6 +559,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const hasTemporalBounds = Boolean(attributes.valid_from || attributes.valid_until);
|
||||
const provenanceCount = getProvenanceCount(attributes.properties ?? {});
|
||||
const properties = attributes.properties as Record<string, unknown>;
|
||||
const entityShape = resolveEntityShape(attributes, semanticGroup);
|
||||
const providedX = readFiniteCoordinate(properties?.x);
|
||||
const providedY = readFiniteCoordinate(properties?.y);
|
||||
const seededPosition = seededPositions?.get(id);
|
||||
@@ -575,6 +587,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
strokeColor: darkenHex(baseColor, 112),
|
||||
borderColor: darkenHex(baseColor, 112),
|
||||
borderSize: 0.72,
|
||||
entityShape,
|
||||
...resolveNodeVariantMetadata(baseColor, sizeRatio, hasTemporalBounds, provenanceCount),
|
||||
} as NodeAttributes,
|
||||
};
|
||||
@@ -598,6 +611,12 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
const parallelIndex = parallelOffsets.get(pairKey) ?? 0;
|
||||
parallelOffsets.set(pairKey, parallelIndex + 1);
|
||||
const parallelCount = parallelCounts.get(pairKey) ?? 1;
|
||||
const normalizedWeight = clamp(0, Math.log1p(Math.max(Number(edge.weight) || 1, 1)) / 6, 1);
|
||||
const edgeVisualPriority = clamp(
|
||||
0,
|
||||
Math.sqrt(Math.max(sourcePriority, 0) * Math.max(targetPriority, 0)) * 0.72 + normalizedWeight * 0.28,
|
||||
1,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
@@ -617,7 +636,7 @@ export function useLoadGraph(options: UseLoadGraphOptions = {}) {
|
||||
color: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
baseColor: GRAPH_THEME.palette.muted.edgeStructure,
|
||||
mutedColor: GRAPH_THEME.palette.muted.edgeOverview,
|
||||
visualPriority: Math.max(sourcePriority, targetPriority),
|
||||
visualPriority: edgeVisualPriority,
|
||||
isBidirectional,
|
||||
edgeFamily: isBidirectional ? "bidirectional" : "line",
|
||||
curveGroup: curveGroupForPair(edge.source, edge.target),
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { GitMerge, Loader2, Sparkles, Trash2 } from "lucide-react";
|
||||
import {
|
||||
loadAlignments,
|
||||
loadOntologyRegistry,
|
||||
removeAlignment,
|
||||
saveAlignment,
|
||||
suggestAlignments,
|
||||
} from "./api";
|
||||
import type { AlignmentRelation, AlignmentSuggestion, OntologyAlignment, OntologyEntry } from "./types";
|
||||
|
||||
const RELATIONS: AlignmentRelation[] = [
|
||||
"owl:equivalentClass",
|
||||
"owl:equivalentProperty",
|
||||
"skos:exactMatch",
|
||||
"skos:closeMatch",
|
||||
"skos:broadMatch",
|
||||
"skos:narrowMatch",
|
||||
"skos:relatedMatch",
|
||||
];
|
||||
|
||||
const RELATION_COLORS: Record<AlignmentRelation, string> = {
|
||||
"owl:equivalentClass": "#7ce7d3",
|
||||
"owl:equivalentProperty": "#7ce7d3",
|
||||
"skos:exactMatch": "#9ee8d7",
|
||||
"skos:closeMatch": "#58a6ff",
|
||||
"skos:broadMatch": "#f2b66d",
|
||||
"skos:narrowMatch": "#f2b66d",
|
||||
"skos:relatedMatch": "#d2a8ff",
|
||||
};
|
||||
|
||||
export function AlignmentsTab() {
|
||||
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
|
||||
const [alignments, setAlignments] = useState<OntologyAlignment[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<AlignmentSuggestion[]>([]);
|
||||
const [sourceOntology, setSourceOntology] = useState("");
|
||||
const [targetOntology, setTargetOntology] = useState("");
|
||||
const [sourceUri, setSourceUri] = useState("");
|
||||
const [targetUri, setTargetUri] = useState("");
|
||||
const [relation, setRelation] = useState<AlignmentRelation>("skos:exactMatch");
|
||||
const [confidence, setConfidence] = useState(0.86);
|
||||
const [provenance, setProvenance] = useState("");
|
||||
const [source, setSource] = useState("Ontology Hub");
|
||||
const [reviewer, setReviewer] = useState("");
|
||||
const [threshold, setThreshold] = useState(0.68);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const [registryData, alignmentData] = await Promise.all([
|
||||
loadOntologyRegistry(),
|
||||
loadAlignments(),
|
||||
]);
|
||||
setRegistry(registryData);
|
||||
setAlignments(alignmentData);
|
||||
setSourceOntology((current) => current || registryData[0]?.uri || "");
|
||||
setTargetOntology((current) => current || registryData[1]?.uri || registryData[0]?.uri || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load ontology alignments.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const relationCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of alignments) {
|
||||
counts.set(item.relation, (counts.get(item.relation) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [alignments]);
|
||||
|
||||
// Pairwise matrix: group alignments by (source_ontology, target_ontology) pair.
|
||||
const matrix = useMemo(() => {
|
||||
function ontologyOfUri(uri: string): string {
|
||||
for (const entry of registry) {
|
||||
if (uri === entry.uri || uri.startsWith(entry.uri + "#") || uri.startsWith(entry.uri + "/")) {
|
||||
return entry.uri;
|
||||
}
|
||||
}
|
||||
const hashIdx = uri.lastIndexOf("#");
|
||||
if (hashIdx > 0) return uri.substring(0, hashIdx);
|
||||
const slashIdx = uri.lastIndexOf("/");
|
||||
return slashIdx > 0 ? uri.substring(0, slashIdx) : uri;
|
||||
}
|
||||
const cells: Map<string, OntologyAlignment[]> = new Map();
|
||||
for (const alignment of alignments) {
|
||||
const key = `${ontologyOfUri(alignment.source_uri)}|||${ontologyOfUri(alignment.target_uri)}`;
|
||||
const bucket = cells.get(key) ?? [];
|
||||
bucket.push(alignment);
|
||||
cells.set(key, bucket);
|
||||
}
|
||||
return { ontologies: registry, cells };
|
||||
}, [registry, alignments]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!sourceUri.trim() || !targetUri.trim()) {
|
||||
setError("Provide both source and target entity URIs.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await saveAlignment({
|
||||
source_uri: sourceUri.trim(),
|
||||
target_uri: targetUri.trim(),
|
||||
relation,
|
||||
confidence,
|
||||
provenance: provenance || undefined,
|
||||
source: source || undefined,
|
||||
reviewer: reviewer || undefined,
|
||||
});
|
||||
setSourceUri("");
|
||||
setTargetUri("");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save alignment.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [sourceUri, targetUri, relation, confidence, provenance, source, reviewer, reload]);
|
||||
|
||||
const handleSuggest = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await suggestAlignments({
|
||||
source_ontology_uri: sourceOntology || undefined,
|
||||
target_ontology_uri: targetOntology || undefined,
|
||||
threshold,
|
||||
limit: 40,
|
||||
});
|
||||
setSuggestions(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not suggest alignments.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [sourceOntology, targetOntology, threshold]);
|
||||
|
||||
const handleAcceptSuggestion = useCallback((suggestion: AlignmentSuggestion) => {
|
||||
setSourceUri(suggestion.source_uri);
|
||||
setTargetUri(suggestion.target_uri);
|
||||
setRelation(suggestion.relation);
|
||||
setConfidence(Math.max(0.1, Math.min(1, suggestion.score)));
|
||||
setProvenance(suggestion.reason);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(async (id: string) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await removeAlignment(id);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not remove alignment.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<section style={heroStyle}>
|
||||
<div>
|
||||
<div style={kickerStyle}><GitMerge size={14} /> Alignment Matrix</div>
|
||||
<h2 style={titleStyle}>Cross-ontology mappings</h2>
|
||||
<p style={textStyle}>
|
||||
Manage equivalence and SKOS match relations with confidence, provenance,
|
||||
reviewer context, and label-based suggestions.
|
||||
</p>
|
||||
</div>
|
||||
<div style={summaryGridStyle}>
|
||||
<Metric label="Mappings" value={alignments.length} />
|
||||
<Metric label="Relations" value={relationCounts.size} />
|
||||
<Metric label="Suggestions" value={suggestions.length} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <div style={errorStyle}>{error}</div> : null}
|
||||
|
||||
<div style={ephemeralBannerStyle}>
|
||||
Alignments are stored in server memory and are not persisted across restarts.
|
||||
Export your graph or ontology to preserve recorded mappings.
|
||||
</div>
|
||||
|
||||
{matrix.ontologies.length >= 2 ? (
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Pairwise alignment matrix</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={matrixTableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={matrixCornerStyle} />
|
||||
{matrix.ontologies.map((col) => (
|
||||
<th key={col.uri} style={matrixColHeaderStyle}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.ontologies.map((row) => (
|
||||
<tr key={row.uri}>
|
||||
<td style={matrixRowHeaderStyle}>{row.name}</td>
|
||||
{matrix.ontologies.map((col) => {
|
||||
const key = `${row.uri}|||${col.uri}`;
|
||||
const cellItems = matrix.cells.get(key) ?? [];
|
||||
const isDiag = row.uri === col.uri;
|
||||
return (
|
||||
<td key={col.uri} style={{ ...matrixCellStyle, background: isDiag ? "rgba(255,255,255,0.015)" : undefined }}>
|
||||
{isDiag ? <span style={mutedStyle}>—</span> : cellItems.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{cellItems.map((item) => (
|
||||
<span
|
||||
key={item.id}
|
||||
style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}44`, cursor: "pointer", fontSize: 9 }}
|
||||
title={`${item.source_label} → ${item.target_label} (${Math.round(item.confidence * 100)}%)`}
|
||||
onClick={() => {
|
||||
setSourceUri(item.source_uri);
|
||||
setTargetUri(item.target_uri);
|
||||
setRelation(item.relation);
|
||||
setConfidence(item.confidence);
|
||||
setProvenance(item.provenance ?? "");
|
||||
}}
|
||||
>
|
||||
{item.relation.split(":")[1]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : <span style={{ color: "rgba(127,208,255,0.15)", fontSize: 12 }}>·</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p style={{ ...mutedStyle, marginTop: 10 }}>Click a relation badge to load it into the editor below.</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div style={gridStyle}>
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Create or update alignment</h3>
|
||||
<label style={labelStyle}>Source entity URI</label>
|
||||
<input style={inputStyle} value={sourceUri} onChange={(event) => setSourceUri(event.target.value)} />
|
||||
<label style={labelStyle}>Target entity URI</label>
|
||||
<input style={inputStyle} value={targetUri} onChange={(event) => setTargetUri(event.target.value)} />
|
||||
<div style={twoColStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Relation</label>
|
||||
<select style={inputStyle} value={relation} onChange={(event) => setRelation(event.target.value as AlignmentRelation)}>
|
||||
{RELATIONS.map((item) => <option key={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Confidence {confidence.toFixed(2)}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={confidence}
|
||||
onChange={(event) => setConfidence(Number(event.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label style={labelStyle}>Provenance note</label>
|
||||
<textarea style={{ ...inputStyle, minHeight: 74, resize: "vertical" }} value={provenance} onChange={(event) => setProvenance(event.target.value)} />
|
||||
<div style={twoColStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Source</label>
|
||||
<input style={inputStyle} value={source} onChange={(event) => setSource(event.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Reviewer</label>
|
||||
<input style={inputStyle} value={reviewer} onChange={(event) => setReviewer(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button style={primaryButtonStyle} disabled={busy} onClick={handleSave}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : <GitMerge size={14} />}
|
||||
Save alignment
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Suggest alignments</h3>
|
||||
<div style={twoColStyle}>
|
||||
<div>
|
||||
<label style={labelStyle}>Source ontology</label>
|
||||
<select style={inputStyle} value={sourceOntology} onChange={(event) => setSourceOntology(event.target.value)}>
|
||||
<option value="">Any ontology</option>
|
||||
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Target ontology</label>
|
||||
<select style={inputStyle} value={targetOntology} onChange={(event) => setTargetOntology(event.target.value)}>
|
||||
<option value="">Any ontology</option>
|
||||
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label style={labelStyle}>Similarity threshold {threshold.toFixed(2)}</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.25"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
value={threshold}
|
||||
onChange={(event) => setThreshold(Number(event.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<button style={secondaryButtonStyle} disabled={busy} onClick={handleSuggest}>
|
||||
<Sparkles size={14} />
|
||||
Suggest alignments
|
||||
</button>
|
||||
<div style={suggestionListStyle}>
|
||||
{suggestions.map((item) => (
|
||||
<button key={`${item.source_uri}-${item.target_uri}-${item.relation}`} style={suggestionStyle} onClick={() => handleAcceptSuggestion(item)}>
|
||||
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label}</span>
|
||||
<span style={{ color: RELATION_COLORS[item.relation] }}>{item.relation}</span>
|
||||
<span style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label}</span>
|
||||
<span style={{ color: "#8fa8c6" }}>{Math.round(item.score * 100)}%</span>
|
||||
</button>
|
||||
))}
|
||||
{!suggestions.length ? <p style={mutedStyle}>Run suggestions to review ranked candidate mappings.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Recorded alignments</h3>
|
||||
<div style={tableStyle}>
|
||||
{alignments.map((item) => (
|
||||
<div key={item.id} style={rowStyle}>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.source_label || item.source_uri}</div>
|
||||
<div style={monoStyle}>{item.source_uri}</div>
|
||||
</div>
|
||||
<div style={{ ...relationBadgeStyle, color: RELATION_COLORS[item.relation], borderColor: `${RELATION_COLORS[item.relation]}55` }}>
|
||||
{item.relation}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{item.target_label || item.target_uri}</div>
|
||||
<div style={monoStyle}>{item.target_uri}</div>
|
||||
</div>
|
||||
<div style={confidenceStyle}>{Math.round(item.confidence * 100)}%</div>
|
||||
<button style={iconButtonStyle} disabled={busy} onClick={() => handleRemove(item.id)} title="Remove alignment">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!alignments.length ? <p style={mutedStyle}>No alignments recorded yet.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div style={metricStyle}>
|
||||
<span style={{ color: "#9ee8d7", fontSize: 20, fontWeight: 900 }}>{value.toLocaleString()}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
|
||||
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
|
||||
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
|
||||
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
|
||||
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
|
||||
const summaryGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(100px, 1fr))", gap: 10, minWidth: 320 };
|
||||
const metricStyle: CSSProperties = { padding: 14, borderRadius: 16, background: "rgba(255,255,255,0.035)", border: "1px solid rgba(127,208,255,0.1)", display: "flex", flexDirection: "column", gap: 4 };
|
||||
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "minmax(320px, 0.9fr) minmax(360px, 1.1fr)", gap: 16 };
|
||||
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)" };
|
||||
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
|
||||
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "10px 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
|
||||
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
|
||||
const twoColStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 };
|
||||
const primaryButtonStyle: CSSProperties = { marginTop: 14, width: "100%", border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "11px 13px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8 };
|
||||
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
|
||||
const suggestionListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8, marginTop: 14, maxHeight: 260, overflow: "auto" };
|
||||
const suggestionStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto", gap: 10, alignItems: "center", textAlign: "left", border: "1px solid rgba(127,208,255,0.1)", borderRadius: 12, background: "rgba(255,255,255,0.03)", padding: 10, cursor: "pointer" };
|
||||
const tableStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
|
||||
const rowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto 1fr auto auto", gap: 12, alignItems: "center", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
|
||||
const monoStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
|
||||
const relationBadgeStyle: CSSProperties = { padding: "5px 9px", border: "1px solid", borderRadius: 999, fontSize: 10, fontWeight: 900 };
|
||||
const confidenceStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
|
||||
const iconButtonStyle: CSSProperties = { width: 34, height: 34, borderRadius: 10, border: "1px solid rgba(255,157,175,0.18)", background: "rgba(255,157,175,0.08)", color: "#ff9daf", cursor: "pointer" };
|
||||
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
|
||||
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
|
||||
const ephemeralBannerStyle: CSSProperties = { padding: "9px 14px", borderRadius: 12, color: "#f2b66d", background: "rgba(242,182,109,0.08)", border: "1px solid rgba(242,182,109,0.22)", fontSize: 12 };
|
||||
const matrixTableStyle: CSSProperties = { borderCollapse: "collapse", minWidth: "100%", fontSize: 12 };
|
||||
const matrixCornerStyle: CSSProperties = { padding: "8px 12px", borderBottom: "1px solid rgba(127,208,255,0.1)" };
|
||||
const matrixColHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderBottom: "1px solid rgba(127,208,255,0.1)", textAlign: "center", whiteSpace: "nowrap" };
|
||||
const matrixRowHeaderStyle: CSSProperties = { padding: "8px 12px", color: "#9ee8d7", fontWeight: 900, borderRight: "1px solid rgba(127,208,255,0.1)", whiteSpace: "nowrap" };
|
||||
const matrixCellStyle: CSSProperties = { padding: "8px 10px", borderBottom: "1px solid rgba(127,208,255,0.06)", borderRight: "1px solid rgba(127,208,255,0.06)", textAlign: "center", verticalAlign: "middle", minWidth: 100 };
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Download, HeartPulse, Loader2, Wrench } from "lucide-react";
|
||||
import { loadOntologyHealth, loadOntologyRegistry } from "./api";
|
||||
import type { OntologyEntry, OntologyHealthResponse, HealthIssue } from "./types";
|
||||
|
||||
interface HealthTabProps {
|
||||
onFixInEditor?: (entityUri: string) => void;
|
||||
}
|
||||
|
||||
export function HealthTab({ onFixInEditor }: HealthTabProps) {
|
||||
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
|
||||
const [selectedUri, setSelectedUri] = useState("");
|
||||
const [health, setHealth] = useState<OntologyHealthResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOntologyRegistry()
|
||||
.then((entries) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
setSelectedUri((current) => current || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadHealth = useCallback(async (uri: string) => {
|
||||
if (!uri) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setHealth(await loadOntologyHealth(uri));
|
||||
} catch (err) {
|
||||
setHealth(null);
|
||||
setError(err instanceof Error ? err.message : "Could not load ontology health.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth(selectedUri);
|
||||
}, [selectedUri, loadHealth]);
|
||||
|
||||
const exportReport = useCallback(() => {
|
||||
if (!health) return;
|
||||
const blob = new Blob([JSON.stringify(health, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${health.name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}-health.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
}, [health]);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<section style={heroStyle}>
|
||||
<div>
|
||||
<div style={kickerStyle}><HeartPulse size={14} /> Ontology Health</div>
|
||||
<h2 style={titleStyle}>Quality and governance signals</h2>
|
||||
<p style={textStyle}>
|
||||
Score completeness, consistency, SHACL readiness, alignment coverage,
|
||||
and documentation quality for the selected ontology.
|
||||
</p>
|
||||
</div>
|
||||
<div style={selectorShellStyle}>
|
||||
<label style={labelStyle}>Ontology</label>
|
||||
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
|
||||
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <div style={errorStyle}>{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<div style={loadingStyle}><Loader2 size={18} className="spin" /> Computing health dashboard...</div>
|
||||
) : health ? (
|
||||
<>
|
||||
<section style={{ ...scoreGridStyle, gridTemplateColumns: `220px repeat(${health.dimensions.length}, minmax(180px, 1fr))` }}>
|
||||
<div style={scoreCardStyle}>
|
||||
<span style={scoreValueStyle}>{Math.round(health.total_score)}</span>
|
||||
<span style={mutedStyle}>Total health score</span>
|
||||
<button style={secondaryButtonStyle} onClick={exportReport}><Download size={14} /> Export report</button>
|
||||
</div>
|
||||
{health.dimensions.map((dimension) => (
|
||||
<div key={dimension.key} style={dimensionCardStyle}>
|
||||
<div style={dimensionHeadStyle}>
|
||||
<span style={{ color: "#ebf3ff", fontWeight: 900 }}>{dimension.label}</span>
|
||||
<span style={statusBadgeStyle(dimension.status)}>{dimension.status}</span>
|
||||
</div>
|
||||
<div style={barTrackStyle}>
|
||||
<div style={{ ...barFillStyle, width: `${dimension.score}%`, background: dimensionColor(dimension.score, dimension.status) }} />
|
||||
</div>
|
||||
<div style={dimensionFootStyle}>
|
||||
<span>{Math.round(dimension.score)} / 100</span>
|
||||
<span>{dimension.detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h3 style={sectionTitleStyle}>Actionable issues</h3>
|
||||
<div style={issueListStyle}>
|
||||
{health.issues.map((issue) => (
|
||||
<IssueRow key={issue.id} issue={issue} onFixInEditor={onFixInEditor} />
|
||||
))}
|
||||
{!health.issues.length ? <p style={mutedStyle}>No actionable issues reported for this ontology.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div style={emptyStyle}>Select an ontology to compute health signals.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueRow({ issue, onFixInEditor }: { issue: HealthIssue; onFixInEditor?: (entityUri: string) => void }) {
|
||||
return (
|
||||
<div style={issueRowStyle}>
|
||||
<div style={severityDotStyle(issue.severity)} />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{issue.entity_label || issue.category}</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, lineHeight: 1.45 }}>{issue.message}</div>
|
||||
{issue.entity_uri ? <div style={monoStyle}>{issue.entity_uri}</div> : null}
|
||||
</div>
|
||||
<span style={categoryStyle}>{issue.category}</span>
|
||||
{issue.entity_uri ? (
|
||||
<button style={smallButtonStyle} onClick={() => onFixInEditor?.(issue.entity_uri || "")}>
|
||||
<Wrench size={13} />
|
||||
Fix in Editor
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function dimensionColor(score: number, status: string) {
|
||||
if (status === "unavailable") return "#6a7f97";
|
||||
if (score >= 80) return "#7ce7d3";
|
||||
if (score >= 55) return "#f2b66d";
|
||||
return "#ff9daf";
|
||||
}
|
||||
|
||||
function statusBadgeStyle(status: string): CSSProperties {
|
||||
const color = status === "ok" ? "#7ce7d3" : status === "unavailable" ? "#6a7f97" : "#f2b66d";
|
||||
return {
|
||||
color,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}30`,
|
||||
borderRadius: 999,
|
||||
padding: "2px 7px",
|
||||
fontSize: 10,
|
||||
fontWeight: 900,
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
}
|
||||
|
||||
function severityDotStyle(severity: string): CSSProperties {
|
||||
const color = severity === "critical" ? "#ff9daf" : severity === "warning" ? "#f2b66d" : "#58a6ff";
|
||||
return { width: 10, height: 10, borderRadius: "50%", background: color, boxShadow: `0 0 18px ${color}55`, marginTop: 5 };
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
|
||||
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
|
||||
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
|
||||
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
|
||||
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 620 };
|
||||
const selectorShellStyle: CSSProperties = { minWidth: 320 };
|
||||
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
|
||||
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
|
||||
const scoreGridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "220px repeat(5, minmax(180px, 1fr))", gap: 12 };
|
||||
const scoreCardStyle: CSSProperties = { padding: 18, borderRadius: 20, background: "rgba(15,35,52,0.88)", border: "1px solid rgba(124,231,211,0.2)", display: "flex", flexDirection: "column", gap: 10 };
|
||||
const scoreValueStyle: CSSProperties = { color: "#9ee8d7", fontSize: 52, lineHeight: 1, fontWeight: 950, letterSpacing: "-0.06em" };
|
||||
const dimensionCardStyle: CSSProperties = { padding: 16, borderRadius: 18, background: "rgba(9,19,34,0.78)", border: "1px solid rgba(127,208,255,0.12)" };
|
||||
const dimensionHeadStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center", marginBottom: 12 };
|
||||
const barTrackStyle: CSSProperties = { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.06)", overflow: "hidden" };
|
||||
const barFillStyle: CSSProperties = { height: "100%", borderRadius: 999 };
|
||||
const dimensionFootStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6, color: "#8fa8c6", fontSize: 12, marginTop: 10 };
|
||||
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
|
||||
const sectionTitleStyle: CSSProperties = { margin: "0 0 14px", color: "#ebf3ff", fontSize: 16 };
|
||||
const issueListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 8 };
|
||||
const issueRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "14px 1fr auto auto", gap: 12, alignItems: "start", padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
|
||||
const categoryStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.22)", borderRadius: 999, padding: "4px 8px", fontSize: 10, fontWeight: 900 };
|
||||
const smallButtonStyle: CSSProperties = { display: "inline-flex", gap: 6, alignItems: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
|
||||
const secondaryButtonStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", justifyContent: "center", border: "1px solid rgba(127,208,255,0.16)", borderRadius: 12, padding: "10px 12px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 900 };
|
||||
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
|
||||
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 13 };
|
||||
const loadingStyle: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 8, color: "#8fa8c6", padding: 18 };
|
||||
const emptyStyle: CSSProperties = { color: "#6a7f97", padding: 22 };
|
||||
const errorStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
|
||||
@@ -0,0 +1,498 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
} from "@xyflow/react";
|
||||
import type { Connection, Edge, Node } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Plus,
|
||||
GitBranch,
|
||||
User,
|
||||
Shield,
|
||||
FileText,
|
||||
Layout,
|
||||
Send,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
type OntologyNodeData = {
|
||||
label?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type OntologyNode = Node<OntologyNodeData>;
|
||||
type OntologyEdge = Edge<Record<string, unknown>>;
|
||||
|
||||
const nodeTypes = {
|
||||
classNode: ({ data }: { data: OntologyNodeData }) => (
|
||||
<div style={classNodeStyle}>
|
||||
<div style={classNodeHeader}>{data.label}</div>
|
||||
<div style={classNodeSub}>{data.type}</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const classNodeStyle: React.CSSProperties = {
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
background: "linear-gradient(135deg, rgba(74, 163, 255, 0.15), rgba(74, 163, 255, 0.05))",
|
||||
border: "1px solid rgba(127, 208, 255, 0.3)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
minWidth: "140px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
};
|
||||
|
||||
const classNodeHeader: React.CSSProperties = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "700",
|
||||
marginBottom: "4px",
|
||||
};
|
||||
|
||||
const classNodeSub: React.CSSProperties = {
|
||||
fontSize: "11px",
|
||||
color: "#8fa8c6",
|
||||
fontWeight: "500",
|
||||
};
|
||||
|
||||
interface DraftDiff {
|
||||
added_classes: string[];
|
||||
removed_classes: string[];
|
||||
modified_classes: Record<string, Record<string, any>>;
|
||||
added_properties: string[];
|
||||
removed_properties: string[];
|
||||
modified_properties: Record<string, Record<string, any>>;
|
||||
added_restrictions: Record<string, any>[];
|
||||
removed_restrictions: Record<string, any>[];
|
||||
added_axioms: Record<string, any>[];
|
||||
removed_axioms: Record<string, any>[];
|
||||
annotation_changes: Record<string, Record<string, any>>;
|
||||
}
|
||||
|
||||
interface RegistryEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function OntologyEditor() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<OntologyNode>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<OntologyEdge>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<OntologyNode | OntologyEdge | null>(null);
|
||||
const [registry, setRegistry] = useState<RegistryEntry[]>([]);
|
||||
const [ontologyUri, setOntologyUri] = useState<string>("");
|
||||
const [draftDiff, setDraftDiff] = useState<DraftDiff>({
|
||||
added_classes: [],
|
||||
removed_classes: [],
|
||||
modified_classes: {},
|
||||
added_properties: [],
|
||||
removed_properties: [],
|
||||
modified_properties: {},
|
||||
added_restrictions: [],
|
||||
removed_restrictions: [],
|
||||
added_axioms: [],
|
||||
removed_axioms: [],
|
||||
annotation_changes: {},
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showContext, setShowContext] = useState<{ x: number; y: number; type: string; element: OntologyNode | OntologyEdge } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/ontology/registry")
|
||||
.then((response) => (response.ok ? response.json() : []))
|
||||
.then((entries: RegistryEntry[]) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
setOntologyUri((current) => current || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load ontology registry:", error);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const addClass = useCallback(() => {
|
||||
const newId = `class_${Date.now()}`;
|
||||
const newNode: OntologyNode = {
|
||||
id: newId,
|
||||
type: "classNode",
|
||||
position: { x: Math.random() * 400, y: Math.random() * 300 },
|
||||
data: { label: "NewClass", type: "owl:Class" },
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
added_classes: [...prev.added_classes, newId],
|
||||
}));
|
||||
}, [setNodes]);
|
||||
|
||||
const addProperty = useCallback(() => {
|
||||
if (nodes.length < 2) {
|
||||
alert("Add at least two classes before creating a property edge.");
|
||||
return;
|
||||
}
|
||||
const newId = `prop_${Date.now()}`;
|
||||
const newEdge: OntologyEdge = {
|
||||
id: newId,
|
||||
source: nodes[0].id,
|
||||
target: nodes[1].id,
|
||||
label: "hasProperty",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
};
|
||||
setEdges((eds) => [...eds, newEdge]);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
added_properties: [...prev.added_properties, newId],
|
||||
}));
|
||||
}, [nodes, setEdges]);
|
||||
|
||||
const addIndividual = useCallback(() => {
|
||||
const newId = `ind_${Date.now()}`;
|
||||
const newNode: OntologyNode = {
|
||||
id: newId,
|
||||
type: "classNode",
|
||||
position: { x: Math.random() * 400, y: Math.random() * 300 },
|
||||
data: { label: "NewIndividual", type: "owl:NamedIndividual" },
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
}, [setNodes]);
|
||||
|
||||
const addRestriction = useCallback(() => {
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
added_restrictions: [...prev.added_restrictions, { type: "someValuesFrom", value: "" }],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addAxiom = useCallback(() => {
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
added_axioms: [...prev.added_axioms, { type: "subClassOf", value: "" }],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const autoLayout = useCallback(() => {
|
||||
const layoutNodes = nodes.map((node, index) => ({
|
||||
...node,
|
||||
position: { x: (index % 4) * 200, y: Math.floor(index / 4) * 150 },
|
||||
}));
|
||||
setNodes(layoutNodes);
|
||||
}, [nodes, setNodes]);
|
||||
|
||||
const saveDraft = useCallback(async () => {
|
||||
if (!ontologyUri) {
|
||||
alert("Please select an ontology first");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/ontology/draft", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ontology_uri: ontologyUri,
|
||||
diff: draftDiff,
|
||||
author: "user",
|
||||
summary: "Visual editor changes",
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
alert(`Draft saved: ${data.draft_id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save draft:", error);
|
||||
alert("Failed to save draft");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [ontologyUri, draftDiff]);
|
||||
|
||||
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: OntologyNode) => {
|
||||
event.preventDefault();
|
||||
setSelectedElement(node);
|
||||
setShowContext({ x: event.clientX, y: event.clientY, type: "node", element: node });
|
||||
}, []);
|
||||
|
||||
const handleEdgeContextMenu = useCallback((event: React.MouseEvent, edge: OntologyEdge) => {
|
||||
event.preventDefault();
|
||||
setSelectedElement(edge);
|
||||
setShowContext({ x: event.clientX, y: event.clientY, type: "edge", element: edge });
|
||||
}, []);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
const target = showContext?.element ?? selectedElement;
|
||||
if (target) {
|
||||
if ("source" in target) {
|
||||
setEdges((eds) => eds.filter((e) => e.id !== target.id));
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
removed_properties: [...prev.removed_properties, target.id],
|
||||
}));
|
||||
} else {
|
||||
setNodes((nds) => nds.filter((n) => n.id !== target.id));
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
removed_classes: [...prev.removed_classes, target.id],
|
||||
}));
|
||||
}
|
||||
setSelectedElement(null);
|
||||
}
|
||||
setShowContext(null);
|
||||
}, [selectedElement, setNodes, setEdges, showContext]);
|
||||
|
||||
const renameSelected = useCallback(() => {
|
||||
const target = showContext?.element ?? selectedElement;
|
||||
if (target && !("source" in target)) {
|
||||
const newLabel = prompt("Enter new name:", String(target.data.label ?? ""));
|
||||
if (newLabel) {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => (n.id === target.id ? { ...n, data: { ...n.data, label: newLabel } } : n))
|
||||
);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
modified_classes: { ...prev.modified_classes, [target.id]: { label: newLabel } },
|
||||
}));
|
||||
}
|
||||
}
|
||||
setShowContext(null);
|
||||
}, [selectedElement, setNodes, showContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => setShowContext(null);
|
||||
window.addEventListener("click", handleClick);
|
||||
return () => window.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
padding: "12px 16px",
|
||||
background: "rgba(3, 9, 18, 0.92)",
|
||||
borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.18)",
|
||||
background: "rgba(74, 163, 255, 0.08)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.18)",
|
||||
background: "rgba(3, 9, 18, 0.88)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "12px",
|
||||
minWidth: "260px",
|
||||
};
|
||||
|
||||
const contextMenuStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
background: "rgba(9, 19, 34, 0.95)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.3)",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 0",
|
||||
minWidth: "180px",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const contextItemStyle: React.CSSProperties = {
|
||||
padding: "8px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "320px",
|
||||
background: "rgba(9, 19, 34, 0.95)",
|
||||
borderLeft: "1px solid rgba(140, 192, 255, 0.12)",
|
||||
padding: "20px",
|
||||
overflow: "auto",
|
||||
backdropFilter: "blur(18px)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: "#07111f" }}>
|
||||
<div style={toolbarStyle}>
|
||||
<select
|
||||
aria-label="Active ontology"
|
||||
value={ontologyUri}
|
||||
onChange={(event) => setOntologyUri(event.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Select ontology...</option>
|
||||
{registry.map((entry) => (
|
||||
<option key={entry.uri} value={entry.uri}>
|
||||
{entry.name || entry.uri}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button style={toolbarButtonStyle} onClick={addClass}>
|
||||
<Plus size={14} />
|
||||
Add Class
|
||||
</button>
|
||||
<button style={toolbarButtonStyle} onClick={addProperty} disabled={nodes.length < 2}>
|
||||
<GitBranch size={14} />
|
||||
Add Property
|
||||
</button>
|
||||
<button style={toolbarButtonStyle} onClick={addIndividual}>
|
||||
<User size={14} />
|
||||
Add Individual
|
||||
</button>
|
||||
<button style={toolbarButtonStyle} onClick={addRestriction}>
|
||||
<Shield size={14} />
|
||||
Add Restriction
|
||||
</button>
|
||||
<button style={toolbarButtonStyle} onClick={addAxiom}>
|
||||
<FileText size={14} />
|
||||
Add Axiom
|
||||
</button>
|
||||
<button style={toolbarButtonStyle} onClick={autoLayout}>
|
||||
<Layout size={14} />
|
||||
Auto Layout
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button style={toolbarButtonStyle} onClick={saveDraft} disabled={isSaving}>
|
||||
<Send size={14} />
|
||||
{isSaving ? "Saving..." : "Propose"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, position: "relative" }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => setSelectedElement(node)}
|
||||
onEdgeClick={(_, edge) => setSelectedElement(edge)}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onEdgeContextMenu={handleEdgeContextMenu}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
style={{ background: "#07111f" }}
|
||||
>
|
||||
<Background color="#1a2d3d" gap={20} />
|
||||
<Controls />
|
||||
<MiniMap nodeColor="#4aa3ff" maskColor="rgba(0,0,0,0.6)" />
|
||||
</ReactFlow>
|
||||
|
||||
{showContext && (
|
||||
<div style={{ ...contextMenuStyle, left: showContext.x, top: showContext.y }}>
|
||||
<div style={contextItemStyle} onClick={renameSelected}>
|
||||
<Pencil size={14} />
|
||||
Rename
|
||||
</div>
|
||||
<div style={contextItemStyle} onClick={deleteSelected}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedElement && (
|
||||
<div style={detailPanelStyle}>
|
||||
<h3 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "16px" }}>
|
||||
{"source" in selectedElement ? "Property Details" : "Class Details"}
|
||||
</h3>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
ID
|
||||
</label>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px", wordBreak: "break-all" }}>
|
||||
{selectedElement.id}
|
||||
</div>
|
||||
</div>
|
||||
{!("source" in selectedElement) && (
|
||||
<>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(selectedElement.data.label ?? "")}
|
||||
onChange={(e) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === selectedElement.id
|
||||
? { ...n, data: { ...n.data, label: e.target.value } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
setDraftDiff((prev) => ({
|
||||
...prev,
|
||||
modified_classes: {
|
||||
...prev.modified_classes,
|
||||
[selectedElement.id]: { label: e.target.value },
|
||||
},
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
background: "rgba(3, 9, 18, 0.8)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "13px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Type
|
||||
</label>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px" }}>
|
||||
{selectedElement.data.type || "owl:Class"}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
FileUp,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
type LoaderMode = "url" | "file" | "create";
|
||||
type CreateMode = "scratch" | "data" | "text";
|
||||
|
||||
interface OntologyPreview {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
version?: string;
|
||||
license?: string;
|
||||
format: string;
|
||||
estimated_triples: number;
|
||||
source_url?: string;
|
||||
}
|
||||
|
||||
interface LoaderProps {
|
||||
onLoaded: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function Badge({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.07em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}33`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<label style={fieldLabelStyle}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
style={{ ...inputStyle, resize: "vertical", fontFamily: "monospace" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCard({ preview }: { preview: OntologyPreview }) {
|
||||
return (
|
||||
<div style={previewCardStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<CheckCircle2 size={16} color="#4cc38a" />
|
||||
<span style={{ color: "#4cc38a", fontSize: 12, fontWeight: 700 }}>
|
||||
Preview ready
|
||||
</span>
|
||||
<Badge label={preview.format} color="#58a6ff" />
|
||||
</div>
|
||||
|
||||
<div style={previewTitleStyle}>{preview.name}</div>
|
||||
{preview.description && (
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 6, lineHeight: 1.5 }}>
|
||||
{preview.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={previewGridStyle}>
|
||||
<PreviewRow label="Namespace" value={preview.namespace || preview.uri} mono />
|
||||
{preview.version && <PreviewRow label="Version" value={preview.version} />}
|
||||
{preview.license && <PreviewRow label="License" value={preview.license} />}
|
||||
<PreviewRow
|
||||
label="Estimated triples"
|
||||
value={preview.estimated_triples.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "#c6d4e3",
|
||||
fontSize: 11,
|
||||
fontFamily: mono ? "monospace" : undefined,
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL Import panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function URLImportPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [format, setFormat] = useState("");
|
||||
const [customName, setCustomName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [preview, setPreview] = useState<OntologyPreview | null>(null);
|
||||
const [previewState, setPreviewState] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!url.trim()) return;
|
||||
setPreviewState("loading");
|
||||
setPreview(null);
|
||||
setErrorMsg("");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: url.trim(), format: format || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Unknown error" }));
|
||||
throw new Error(err.detail || "Preview failed");
|
||||
}
|
||||
setPreview(await res.json());
|
||||
setPreviewState("idle");
|
||||
} catch (e) {
|
||||
setPreviewState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Could not fetch preview");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (!url.trim()) return;
|
||||
setLoadState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
format: format || undefined,
|
||||
name: customName || undefined,
|
||||
description: description || undefined,
|
||||
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Load failed" }));
|
||||
throw new Error(err.detail || "Load failed");
|
||||
}
|
||||
setLoadState("success");
|
||||
setTimeout(() => {
|
||||
setLoadState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setLoadState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Load failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<FieldGroup label="Ontology URL">
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setPreview(null);
|
||||
setPreviewState("idle");
|
||||
}}
|
||||
placeholder="https://schema.org/version/latest/schema.ttl"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!url.trim() || previewState === "loading"}
|
||||
style={previewBtnStyle}
|
||||
>
|
||||
{previewState === "loading" ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
) : (
|
||||
"Fetch Preview"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{previewState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && <PreviewCard preview={preview} />}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
style={advancedToggleStyle}
|
||||
>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{ transform: showAdvanced ? "rotate(180deg)" : undefined, transition: "200ms" }}
|
||||
/>
|
||||
Advanced options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<FieldGroup label="Format override">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
<option value="turtle">Turtle (.ttl)</option>
|
||||
<option value="xml">RDF/XML (.rdf, .owl)</option>
|
||||
<option value="nt">N-Triples (.nt)</option>
|
||||
<option value="json-ld">JSON-LD (.jsonld)</option>
|
||||
</select>
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Custom display name">
|
||||
<Input value={customName} onChange={setCustomName} placeholder="Leave blank to use ontology title" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Description">
|
||||
<Input value={description} onChange={setDescription} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
<FieldGroup label="Tags (comma-separated)">
|
||||
<Input value={tags} onChange={setTags} placeholder="e.g. biology, upper-ontology" />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology loaded successfully</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!url.trim() || loadState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{loadState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe size={13} />
|
||||
Load Ontology
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File Upload panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileUploadPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [format, setFormat] = useState("");
|
||||
const [loadState, setLoadState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setFileName(file.name);
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const fmtMap: Record<string, string> = {
|
||||
ttl: "turtle", rdf: "xml", owl: "xml", xml: "xml",
|
||||
nt: "nt", jsonld: "json-ld", json: "json-ld",
|
||||
};
|
||||
// Leave format empty for unknown extensions so the backend auto-detects
|
||||
setFormat(fmtMap[ext] ?? "");
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setContent(e.target?.result as string || "");
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (!content) return;
|
||||
setLoadState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// Omit format when empty so the backend _detect_format() runs
|
||||
body: JSON.stringify({ content, ...(format ? { format } : {}) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Load failed" }));
|
||||
throw new Error(err.detail || "Load failed");
|
||||
}
|
||||
setLoadState("success");
|
||||
setTimeout(() => {
|
||||
setLoadState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setLoadState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Load failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<div
|
||||
style={{
|
||||
...dropzoneStyle,
|
||||
borderColor: dragging
|
||||
? "rgba(74,163,255,0.5)"
|
||||
: "rgba(127,208,255,0.18)",
|
||||
background: dragging ? "rgba(74,163,255,0.06)" : undefined,
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<FileUp size={24} color="#4aa3ff" />
|
||||
{fileName ? (
|
||||
<div style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 600 }}>{fileName}</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13 }}>
|
||||
Drop a file here or <span style={{ color: "#4aa3ff" }}>browse</span>
|
||||
</div>
|
||||
<div style={{ color: "#5a7a9a", fontSize: 11 }}>
|
||||
.ttl · .rdf · .owl · .xml · .nt · .jsonld · .json · .n3
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".ttl,.rdf,.owl,.nt,.jsonld,.json,.xml,.n3"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{content && (
|
||||
<FieldGroup label="Format">
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="turtle">Turtle</option>
|
||||
<option value="xml">RDF/XML</option>
|
||||
<option value="nt">N-Triples</option>
|
||||
<option value="json-ld">JSON-LD</option>
|
||||
</select>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{loadState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology loaded successfully — {fileName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!content || loadState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{loadState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp size={13} />
|
||||
Load File
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create New panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateNewPanel({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("scratch");
|
||||
const [namespace, setNamespace] = useState("https://example.org/ontology/");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [sampleData, setSampleData] = useState("");
|
||||
const [schemaText, setSchemaText] = useState("");
|
||||
const [createState, setCreateState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !namespace.trim()) return;
|
||||
setCreateState("loading");
|
||||
try {
|
||||
const res = await fetch("/api/ontology/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: createMode,
|
||||
namespace: namespace.trim(),
|
||||
name: name.trim(),
|
||||
description: description || undefined,
|
||||
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
|
||||
sample_data: createMode === "data" ? sampleData : undefined,
|
||||
schema_text: createMode === "text" ? schemaText : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Create failed" }));
|
||||
throw new Error(err.detail || "Create failed");
|
||||
}
|
||||
setCreateState("success");
|
||||
setTimeout(() => {
|
||||
setCreateState("idle");
|
||||
onLoaded();
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
setCreateState("error");
|
||||
setErrorMsg(e instanceof Error ? e.message : "Create failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelBodyStyle}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{(["scratch", "data", "text"] as CreateMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setCreateMode(m)}
|
||||
style={{
|
||||
...modeTabBase,
|
||||
...(createMode === m ? modeTabActive : modeTabIdle),
|
||||
}}
|
||||
>
|
||||
{m === "scratch" ? "From Scratch" : m === "data" ? "From Data" : "From Text"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FieldGroup label="Display Name *">
|
||||
<Input value={name} onChange={setName} placeholder="My Ontology" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Namespace URI *">
|
||||
<Input value={namespace} onChange={setNamespace} placeholder="https://example.org/onto/" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Description">
|
||||
<Input value={description} onChange={setDescription} placeholder="Optional description" />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup label="Tags (comma-separated)">
|
||||
<Input value={tags} onChange={setTags} placeholder="e.g. internal, draft" />
|
||||
</FieldGroup>
|
||||
|
||||
{createMode === "data" && (
|
||||
<FieldGroup label="Sample Data (JSON or CSV)">
|
||||
<Textarea
|
||||
value={sampleData}
|
||||
onChange={setSampleData}
|
||||
placeholder={'[{"name": "Alice", "age": 30, "city": "Berlin"}]'}
|
||||
rows={6}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{createMode === "text" && (
|
||||
<FieldGroup label="Schema Requirements (natural language)">
|
||||
<Textarea
|
||||
value={schemaText}
|
||||
onChange={setSchemaText}
|
||||
placeholder="Describe the ontology you need. E.g.: I need an ontology for a hospital domain with patients, doctors, appointments, and medications."
|
||||
rows={6}
|
||||
/>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{createState === "success" && (
|
||||
<div style={successBoxStyle}>
|
||||
<CheckCircle2 size={13} />
|
||||
<span>Ontology created and opened in the Registry</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createState === "error" && (
|
||||
<div style={errorBoxStyle}>
|
||||
<AlertCircle size={13} />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!name.trim() || !namespace.trim() || createState === "loading"}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{createState === "loading" ? (
|
||||
<>
|
||||
<Loader2 size={13} style={{ animation: "spin 1s linear infinite" }} />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={13} />
|
||||
Create Ontology
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main OntologyLoader modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologyLoader({ onLoaded, onClose }: LoaderProps) {
|
||||
const [mode, setMode] = useState<LoaderMode>("url");
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div style={modalStyle}>
|
||||
<div style={modalHeaderStyle}>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontSize: 16, fontWeight: 800 }}>Load Ontology</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, marginTop: 2 }}>
|
||||
Import from URL, upload a file, or create a new ontology
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeIconBtnStyle}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 2, padding: "0 20px", borderBottom: "1px solid rgba(127,208,255,0.1)" }}>
|
||||
{(["url", "file", "create"] as LoaderMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
style={{
|
||||
...modalTabBase,
|
||||
...(mode === m ? modalTabActive : modalTabIdle),
|
||||
}}
|
||||
>
|
||||
{m === "url" ? (
|
||||
<><Globe size={12} /> URL Import</>
|
||||
) : m === "file" ? (
|
||||
<><FileUp size={12} /> File Upload</>
|
||||
) : (
|
||||
<><Plus size={12} /> Create New</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={modalBodyStyle}>
|
||||
{mode === "url" && <URLImportPanel onLoaded={onLoaded} />}
|
||||
{mode === "file" && <FileUploadPanel onLoaded={onLoaded} />}
|
||||
{mode === "create" && <CreateNewPanel onLoaded={onLoaded} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(3,9,18,0.78)",
|
||||
backdropFilter: "blur(6px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: "min(620px, 96vw)",
|
||||
maxHeight: "88vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 20,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "linear-gradient(180deg, rgba(11,21,34,0.98), rgba(6,13,22,0.96))",
|
||||
boxShadow: "0 32px 80px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
padding: "20px 20px 16px",
|
||||
};
|
||||
|
||||
const modalBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
};
|
||||
|
||||
const panelBodyStyle: React.CSSProperties = {
|
||||
padding: "16px 20px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
};
|
||||
|
||||
const modalTabBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
border: "none",
|
||||
borderBottom: "2px solid transparent",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const modalTabIdle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
};
|
||||
|
||||
const modalTabActive: React.CSSProperties = {
|
||||
color: "#4aa3ff",
|
||||
borderBottomColor: "#4aa3ff",
|
||||
};
|
||||
|
||||
const closeIconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
...inputStyle,
|
||||
appearance: "none" as const,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const previewBtnStyle: React.CSSProperties = {
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.2)",
|
||||
background: "rgba(74,163,255,0.08)",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
};
|
||||
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "9px 18px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(74,163,255,0.3)",
|
||||
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.1))",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const advancedToggleStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6a7f97",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
const previewCardStyle: React.CSSProperties = {
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(76,195,138,0.18)",
|
||||
background: "rgba(76,195,138,0.04)",
|
||||
};
|
||||
|
||||
const previewTitleStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.03em",
|
||||
};
|
||||
|
||||
const previewGridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
};
|
||||
|
||||
const dropzoneStyle: React.CSSProperties = {
|
||||
border: "2px dashed",
|
||||
borderRadius: 12,
|
||||
padding: "32px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const successBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(76,195,138,0.22)",
|
||||
background: "rgba(76,195,138,0.06)",
|
||||
color: "#4cc38a",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const errorBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,157,175,0.22)",
|
||||
background: "rgba(255,157,175,0.06)",
|
||||
color: "#ff9daf",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const modeTabBase: React.CSSProperties = {
|
||||
padding: "6px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const modeTabIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const modeTabActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.24)",
|
||||
};
|
||||
@@ -0,0 +1,915 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
Layers,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
} from "lucide-react";
|
||||
import { OntologyLoader } from "./OntologyLoader";
|
||||
import { OntologySearch } from "./OntologySearch";
|
||||
import { SKOSVocabularyManager } from "./SKOSVocabularyManager";
|
||||
|
||||
interface OntologyEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
format: string;
|
||||
status: "published" | "draft" | "external";
|
||||
source_url?: string;
|
||||
version?: string;
|
||||
class_count: number;
|
||||
concept_count: number;
|
||||
property_count: number;
|
||||
loaded_at: string;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
type RightPanel = "none" | "search" | "skos";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
published: "#4cc38a",
|
||||
draft: "#f2b66d",
|
||||
external: "#58a6ff",
|
||||
};
|
||||
|
||||
const FORMAT_COLORS: Record<string, string> = {
|
||||
turtle: "#9ee8d7",
|
||||
xml: "#ff9daf",
|
||||
"json-ld": "#f2b66d",
|
||||
nt: "#d2a8ff",
|
||||
unknown: "#6a7f97",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const color = STATUS_COLORS[status] || "#6a7f97";
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.07em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}33`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatBadge({ format }: { format: string }) {
|
||||
const color = FORMAT_COLORS[format] || FORMAT_COLORS.unknown;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: `${color}14`,
|
||||
border: `1px solid ${color}28`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{format}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<span style={{ color: "#ebf3ff", fontSize: 14, fontWeight: 800 }}>
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistryRow({
|
||||
entry,
|
||||
selected,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: OntologyEntry;
|
||||
selected: boolean;
|
||||
onSelect: (e: OntologyEntry) => void;
|
||||
onToggle: (uri: string) => void;
|
||||
onRefresh: (uri: string) => void;
|
||||
onRemove: (uri: string) => void;
|
||||
}) {
|
||||
const [busyToggle, setBusyToggle] = useState(false);
|
||||
const [busyRefresh, setBusyRefresh] = useState(false);
|
||||
const [busyRemove, setBusyRemove] = useState(false);
|
||||
|
||||
const handleToggle = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setBusyToggle(true);
|
||||
await onToggle(entry.uri);
|
||||
setBusyToggle(false);
|
||||
};
|
||||
|
||||
const handleRefresh = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setBusyRefresh(true);
|
||||
await onRefresh(entry.uri);
|
||||
setBusyRefresh(false);
|
||||
};
|
||||
|
||||
const handleRemove = async (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!window.confirm(`Remove "${entry.name}" from the registry?`)) return;
|
||||
setBusyRemove(true);
|
||||
await onRemove(entry.uri);
|
||||
setBusyRemove(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(entry)}
|
||||
style={{
|
||||
...rowStyle,
|
||||
background: selected
|
||||
? "rgba(74,163,255,0.1)"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
borderColor: selected
|
||||
? "rgba(127,208,255,0.26)"
|
||||
: "rgba(127,208,255,0.1)",
|
||||
opacity: entry.enabled ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<div style={rowMainStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={rowNameStyle}>{entry.name}</span>
|
||||
<StatusBadge status={entry.status} />
|
||||
<FormatBadge format={entry.format} />
|
||||
{!entry.enabled && (
|
||||
<span style={disabledBadgeStyle}>Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={rowUriStyle}>{entry.uri}</div>
|
||||
{entry.source_url && (
|
||||
<a
|
||||
href={entry.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={sourceLinkStyle}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{entry.source_url.slice(0, 60)}{entry.source_url.length > 60 ? "…" : ""}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={rowStatsStyle}>
|
||||
<Stat value={entry.class_count} label="Classes" />
|
||||
<Stat value={entry.concept_count} label="Concepts" />
|
||||
<Stat value={entry.property_count} label="Props" />
|
||||
</div>
|
||||
|
||||
<div style={rowActionsStyle}>
|
||||
<button
|
||||
title={entry.enabled ? "Disable" : "Enable"}
|
||||
onClick={handleToggle}
|
||||
disabled={busyToggle}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
{busyToggle ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : entry.enabled ? (
|
||||
<ToggleRight size={15} color="#4cc38a" />
|
||||
) : (
|
||||
<ToggleLeft size={15} color="#6a7f97" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{entry.source_url && (
|
||||
<button
|
||||
title="Re-fetch from source URL"
|
||||
onClick={handleRefresh}
|
||||
disabled={busyRefresh}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
{busyRefresh ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : (
|
||||
<RefreshCw size={13} color="#58a6ff" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
title="Remove from registry"
|
||||
onClick={handleRemove}
|
||||
disabled={busyRemove}
|
||||
style={{ ...actionBtnStyle, color: "#ff9daf" }}
|
||||
>
|
||||
{busyRemove ? (
|
||||
<Loader2 size={13} style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
) : (
|
||||
<Trash2 size={13} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologyManager() {
|
||||
const [entries, setEntries] = useState<OntologyEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
const [selectedEntry, setSelectedEntry] = useState<OntologyEntry | null>(null);
|
||||
const [rightPanel, setRightPanel] = useState<RightPanel>("none");
|
||||
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const fetchRegistry = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (searchQ) params.set("q", searchQ);
|
||||
// format/kind filters (owl/skos/internal/external) are applied client-side
|
||||
// via filteredEntries; only text search is delegated to the backend
|
||||
const res = await fetch(`/api/ontology/registry?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load registry");
|
||||
setEntries(await res.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load registry");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQ, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegistry();
|
||||
}, [fetchRegistry]);
|
||||
|
||||
const flashMsg = (type: "ok" | "err", text: string) => {
|
||||
setActionMsg({ type, text });
|
||||
setTimeout(() => setActionMsg(null), 3000);
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/toggle`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!res.ok) throw new Error("Toggle failed");
|
||||
const data = await res.json();
|
||||
setEntries((prev) =>
|
||||
prev.map((e) => (e.uri === uri ? { ...e, enabled: data.enabled } : e))
|
||||
);
|
||||
} catch {
|
||||
flashMsg("err", "Could not toggle ontology");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}/refresh`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
flashMsg("ok", "Ontology refreshed");
|
||||
fetchRegistry();
|
||||
} catch {
|
||||
flashMsg("err", "Refresh failed — check source URL");
|
||||
}
|
||||
}, [fetchRegistry]);
|
||||
|
||||
const handleRemove = useCallback(async (uri: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/ontology/${encodeURIComponent(uri)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Remove failed");
|
||||
setEntries((prev) => prev.filter((e) => e.uri !== uri));
|
||||
if (selectedEntry?.uri === uri) setSelectedEntry(null);
|
||||
flashMsg("ok", "Removed from registry");
|
||||
} catch {
|
||||
flashMsg("err", "Could not remove ontology");
|
||||
}
|
||||
}, [selectedEntry]);
|
||||
|
||||
const handleSelect = (entry: OntologyEntry) => {
|
||||
setSelectedEntry((prev) => (prev?.uri === entry.uri ? null : entry));
|
||||
setRightPanel("none");
|
||||
};
|
||||
|
||||
const handleLoaded = () => {
|
||||
setShowLoader(false);
|
||||
fetchRegistry();
|
||||
};
|
||||
|
||||
const filteredEntries = entries.filter((e) => {
|
||||
if (statusFilter === "owl") return ["owl:Ontology"].includes(e.format) || e.format === "xml" || e.format === "turtle";
|
||||
if (statusFilter === "skos") return e.concept_count > 0;
|
||||
if (statusFilter === "internal") return e.status === "draft" || e.status === "published";
|
||||
if (statusFilter === "external") return e.status === "external";
|
||||
return true;
|
||||
});
|
||||
|
||||
const isSKOS = selectedEntry ? selectedEntry.concept_count > 0 : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLoader && (
|
||||
<OntologyLoader
|
||||
onLoaded={handleLoaded}
|
||||
onClose={() => setShowLoader(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={shellStyle}>
|
||||
{/* Toolbar */}
|
||||
<div style={toolbarStyle}>
|
||||
<div style={searchBoxStyle}>
|
||||
<Search size={14} color="#6a7f97" />
|
||||
<input
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder="Search ontologies by name, URI, or namespace…"
|
||||
style={searchInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{(["all", "owl", "skos", "internal", "external"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
style={{
|
||||
...filterPillBase,
|
||||
...(statusFilter === f ? filterPillActive : filterPillIdle),
|
||||
}}
|
||||
>
|
||||
{f === "all" ? "All" : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginLeft: "auto" }}>
|
||||
<button
|
||||
onClick={() => setRightPanel((p) => (p === "search" ? "none" : "search"))}
|
||||
style={{
|
||||
...toolBtnStyle,
|
||||
...(rightPanel === "search" ? toolBtnActive : {}),
|
||||
}}
|
||||
>
|
||||
<Search size={13} />
|
||||
Entity Search
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoader(true)}
|
||||
style={primaryToolBtnStyle}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionMsg && (
|
||||
<div
|
||||
style={{
|
||||
...actionMsgStyle,
|
||||
borderColor:
|
||||
actionMsg.type === "ok"
|
||||
? "rgba(76,195,138,0.22)"
|
||||
: "rgba(255,157,175,0.22)",
|
||||
background:
|
||||
actionMsg.type === "ok"
|
||||
? "rgba(76,195,138,0.06)"
|
||||
: "rgba(255,157,175,0.06)",
|
||||
color: actionMsg.type === "ok" ? "#4cc38a" : "#ff9daf",
|
||||
}}
|
||||
>
|
||||
{actionMsg.type === "ok" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<AlertCircle size={13} />
|
||||
)}
|
||||
{actionMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div style={mainAreaStyle}>
|
||||
{/* Registry list */}
|
||||
<div style={listPanelStyle}>
|
||||
{loading ? (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={22} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
<span style={{ color: "#8fa8c6", fontSize: 13, marginTop: 10 }}>Loading registry…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={22} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 13, marginTop: 8 }}>{error}</span>
|
||||
<button onClick={fetchRegistry} style={retryBtnStyle}>Retry</button>
|
||||
</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div style={emptyStateStyle}>
|
||||
<GitMerge size={36} color="rgba(74,163,255,0.15)" />
|
||||
<div style={{ color: "#8fa8c6", fontSize: 13, marginTop: 12 }}>
|
||||
{searchQ ? "No ontologies match your search" : "No ontologies loaded yet"}
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 12, marginTop: 4, textAlign: "center", maxWidth: 300 }}>
|
||||
Click <strong style={{ color: "#7fd0ff" }}>Load Ontology</strong> to import from a URL, upload a file, or create a new ontology.
|
||||
</div>
|
||||
<button onClick={() => setShowLoader(true)} style={{ ...primaryToolBtnStyle, marginTop: 16 }}>
|
||||
<Plus size={13} />
|
||||
Load Ontology
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={listStyle}>
|
||||
<div style={listHeaderStyle}>
|
||||
<span style={listHeaderTextStyle}>
|
||||
{filteredEntries.length} ontolog{filteredEntries.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</div>
|
||||
{filteredEntries.map((entry) => (
|
||||
<RegistryRow
|
||||
key={entry.uri}
|
||||
entry={entry}
|
||||
selected={selectedEntry?.uri === entry.uri}
|
||||
onSelect={handleSelect}
|
||||
onToggle={handleToggle}
|
||||
onRefresh={handleRefresh}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
{rightPanel === "search" && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>Entity Search</span>
|
||||
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
<OntologySearch />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanel === "none" && selectedEntry && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>{selectedEntry.name}</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{isSKOS && (
|
||||
<button
|
||||
onClick={() => setRightPanel("skos")}
|
||||
style={browseBtnStyle}
|
||||
>
|
||||
<BookOpen size={12} />
|
||||
Browse SKOS
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setSelectedEntry(null)} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={detailBodyStyle}>
|
||||
<DetailSection label="URI">
|
||||
<span style={{ fontFamily: "monospace", fontSize: 11, wordBreak: "break-all", color: "#c6d4e3" }}>
|
||||
{selectedEntry.uri}
|
||||
</span>
|
||||
</DetailSection>
|
||||
{selectedEntry.description && (
|
||||
<DetailSection label="Description">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{selectedEntry.description}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.source_url && (
|
||||
<DetailSection label="Source URL">
|
||||
<a
|
||||
href={selectedEntry.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#58a6ff", fontSize: 11, wordBreak: "break-all" }}
|
||||
>
|
||||
{selectedEntry.source_url}
|
||||
</a>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.version && (
|
||||
<DetailSection label="Version">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12 }}>{selectedEntry.version}</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
{selectedEntry.loaded_at && (
|
||||
<DetailSection label="Loaded at">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12 }}>
|
||||
{new Date(selectedEntry.loaded_at).toLocaleString()}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
<div style={statRowStyle}>
|
||||
<StatBlock value={selectedEntry.class_count} label="Classes" color="#d2a8ff" />
|
||||
<StatBlock value={selectedEntry.concept_count} label="Concepts" color="#9ee8d7" />
|
||||
<StatBlock value={selectedEntry.property_count} label="Properties" color="#f2b66d" />
|
||||
</div>
|
||||
{selectedEntry.tags.length > 0 && (
|
||||
<DetailSection label="Tags">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{selectedEntry.tags.map((tag) => (
|
||||
<span key={tag} style={tagChipStyle}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanel === "skos" && selectedEntry && (
|
||||
<div style={rightPanelStyle}>
|
||||
<div style={rightPanelHeaderStyle}>
|
||||
<span style={rightPanelTitleStyle}>SKOS — {selectedEntry.name}</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button onClick={() => setRightPanel("none")} style={browseBtnStyle}>
|
||||
<Layers size={12} />
|
||||
Registry Detail
|
||||
</button>
|
||||
<button onClick={() => setRightPanel("none")} style={closePanelBtnStyle}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<SKOSVocabularyManager schemeUri={selectedEntry.uri} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── sub-components ─────────────────────────────────────────────────── */
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.05)", paddingTop: 10, paddingBottom: 2 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBlock({ value, label, color }: { value: number; label: string; color: string }) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 2, padding: "10px 6px", background: "rgba(255,255,255,0.02)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.05)" }}>
|
||||
<span style={{ color, fontSize: 18, fontWeight: 800 }}>{value.toLocaleString()}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#0a1525",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(5,12,22,0.72)",
|
||||
flexWrap: "wrap",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const searchBoxStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.14)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
flex: "0 0 280px",
|
||||
};
|
||||
|
||||
const searchInputStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 12,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const filterPillBase: React.CSSProperties = {
|
||||
padding: "5px 11px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const filterPillIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const filterPillActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.26)",
|
||||
};
|
||||
|
||||
const toolBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.16)",
|
||||
background: "rgba(74,163,255,0.06)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const toolBtnActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.16)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.28)",
|
||||
};
|
||||
|
||||
const primaryToolBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "7px 14px",
|
||||
borderRadius: 9,
|
||||
border: "1px solid rgba(74,163,255,0.3)",
|
||||
background: "linear-gradient(135deg, rgba(74,163,255,0.2), rgba(74,163,255,0.08))",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const actionMsgStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 18px",
|
||||
fontSize: 12,
|
||||
borderBottom: "1px solid",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const mainAreaStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const listPanelStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowY: "auto",
|
||||
borderRight: "1px solid rgba(127,208,255,0.08)",
|
||||
};
|
||||
|
||||
const listStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "12px 14px",
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const listHeaderStyle: React.CSSProperties = {
|
||||
paddingBottom: 6,
|
||||
};
|
||||
|
||||
const listHeaderTextStyle: React.CSSProperties = {
|
||||
color: "#6a7f97",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const rowMainStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const rowNameStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
const rowUriStyle: React.CSSProperties = {
|
||||
color: "#6a7f97",
|
||||
fontSize: 11,
|
||||
fontFamily: "monospace",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const sourceLinkStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
const rowStatsStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 16,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rowActionsStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 5,
|
||||
borderRadius: 6,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#8fa8c6",
|
||||
};
|
||||
|
||||
const rightPanelStyle: React.CSSProperties = {
|
||||
width: 360,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(5,12,22,0.6)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const rightPanelHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "14px 16px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.1)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const rightPanelTitleStyle: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const closePanelBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: "0 2px",
|
||||
};
|
||||
|
||||
const browseBtnStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 10px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
background: "rgba(74,163,255,0.06)",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
padding: "14px 16px",
|
||||
overflowY: "auto",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
};
|
||||
|
||||
const statRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const tagChipStyle: React.CSSProperties = {
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const disabledBadgeStyle: React.CSSProperties = {
|
||||
padding: "2px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
background: "rgba(106,127,151,0.12)",
|
||||
border: "1px solid rgba(106,127,151,0.2)",
|
||||
color: "#6a7f97",
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 40,
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
...centerStyle,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const retryBtnStyle: React.CSSProperties = {
|
||||
marginTop: 12,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
background: "transparent",
|
||||
color: "#7fd0ff",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SearchResult {
|
||||
uri: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entity_type: string;
|
||||
definition?: string;
|
||||
source_ontology?: string;
|
||||
namespace_prefix?: string;
|
||||
}
|
||||
|
||||
interface EntityDetail {
|
||||
uri: string;
|
||||
label: string;
|
||||
type: string;
|
||||
entity_type: string;
|
||||
definition?: string;
|
||||
source_ontology?: string;
|
||||
superclasses: string[];
|
||||
subclasses: string[];
|
||||
domain: string[];
|
||||
range: string[];
|
||||
instance_count: number;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_COLORS: Record<string, string> = {
|
||||
class: "#d2a8ff",
|
||||
property: "#f2b66d",
|
||||
individual: "#9ee8d7",
|
||||
concept: "#58a6ff",
|
||||
scheme: "#7fd0ff",
|
||||
unknown: "#6a7f97",
|
||||
};
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
class: "Class",
|
||||
property: "Property",
|
||||
individual: "Individual",
|
||||
concept: "Concept",
|
||||
scheme: "Scheme",
|
||||
unknown: "Entity",
|
||||
};
|
||||
|
||||
function TypeBadge({ entityType }: { entityType: string }) {
|
||||
const color = ENTITY_TYPE_COLORS[entityType] || ENTITY_TYPE_COLORS.unknown;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "1px 7px",
|
||||
borderRadius: 999,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase" as const,
|
||||
background: `${color}14`,
|
||||
border: `1px solid ${color}28`,
|
||||
color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{ENTITY_TYPE_LABELS[entityType] || entityType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UriRef({ uri }: { uri: string }) {
|
||||
const short = uri.includes("#")
|
||||
? uri.split("#").pop() || uri
|
||||
: uri.split("/").pop() || uri;
|
||||
return (
|
||||
<span
|
||||
title={uri}
|
||||
style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}
|
||||
>
|
||||
{short}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
result,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
result: SearchResult;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
background: selected ? "rgba(74,163,255,0.1)" : "rgba(255,255,255,0.02)",
|
||||
borderColor: selected ? "rgba(127,208,255,0.24)" : "rgba(127,208,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{result.label || result.uri}
|
||||
</span>
|
||||
<TypeBadge entityType={result.entity_type} />
|
||||
</div>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{result.uri}
|
||||
</div>
|
||||
{result.definition && (
|
||||
<div style={{ color: "#8fa8c6", fontSize: 12, lineHeight: 1.4, overflow: "hidden", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as const }}>
|
||||
{result.definition}
|
||||
</div>
|
||||
)}
|
||||
{result.source_ontology && (
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10 }}>
|
||||
From: {result.source_ontology}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleList({ label, items }: { label: string; items: string[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={collapseHdrStyle}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>{label}</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>({items.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginLeft: 16, marginTop: 4, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
{items.slice(0, 12).map((uri) => (
|
||||
<div key={uri} style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 10 }}>→</span>
|
||||
<UriRef uri={uri} />
|
||||
</div>
|
||||
))}
|
||||
{items.length > 12 && (
|
||||
<span style={{ color: "#5a7a9a", fontSize: 10 }}>+{items.length - 12} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
uri,
|
||||
onClose,
|
||||
}: {
|
||||
uri: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<EntityDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetch(`/api/ontology/entity/${encodeURIComponent(uri)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Not found");
|
||||
return r.json();
|
||||
})
|
||||
.then(setDetail)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [uri]);
|
||||
|
||||
return (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={detailHeaderStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
||||
<BookOpen size={14} color="#d2a8ff" />
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
Entity Detail
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeDetailBtnStyle}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && !loading && (
|
||||
<div style={detailBodyStyle}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", marginBottom: 4 }}>
|
||||
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
{detail.label || detail.uri.split("/").pop()}
|
||||
</h3>
|
||||
<TypeBadge entityType={detail.entity_type} />
|
||||
</div>
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{detail.uri}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.definition && (
|
||||
<DetailSection label="Definition">
|
||||
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{detail.definition}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{detail.instance_count > 0 && (
|
||||
<DetailSection label="Instances">
|
||||
<span style={{ color: "#9ee8d7", fontSize: 14, fontWeight: 800 }}>
|
||||
{detail.instance_count.toLocaleString()}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
<CollapsibleList label="Superclasses / Broader" items={detail.superclasses} />
|
||||
<CollapsibleList label="Subclasses / Narrower" items={detail.subclasses} />
|
||||
<CollapsibleList label="Domain" items={detail.domain} />
|
||||
<CollapsibleList label="Range" items={detail.range} />
|
||||
|
||||
{detail.source_ontology && (
|
||||
<DetailSection label="Source Ontology">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 12, fontFamily: "monospace" }}>
|
||||
{detail.source_ontology}
|
||||
</span>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={detail.uri}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={openUriStyle}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
Open URI
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main OntologySearch component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OntologySearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [entityType, setEntityType] = useState<string>("all");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedUri, setSelectedUri] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const runSearch = async (q: string, type: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ q: q.trim(), limit: "80" });
|
||||
if (type !== "all") params.set("entity_type", type);
|
||||
const res = await fetch(`/api/ontology/search?${params}`);
|
||||
if (!res.ok) throw new Error("Search failed");
|
||||
setResults(await res.json());
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => runSearch(query, entityType), 320);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [query, entityType]);
|
||||
|
||||
return (
|
||||
<div style={searchShellStyle}>
|
||||
{/* Search input */}
|
||||
<div style={searchTopStyle}>
|
||||
<div style={searchBarStyle}>
|
||||
<Search size={14} color="#6a7f97" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search classes, properties, concepts…"
|
||||
style={searchInputStyle}
|
||||
/>
|
||||
{searching && <Loader2 size={13} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite", flexShrink: 0 }} />}
|
||||
{query && !searching && (
|
||||
<button onClick={() => { setQuery(""); setResults([]); }} style={clearBtnStyle}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={typeFilterStyle}>
|
||||
{(["all", "class", "property", "individual", "concept", "scheme"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setEntityType(t)}
|
||||
style={{
|
||||
...typeFilterBtnBase,
|
||||
...(entityType === t ? typeFilterBtnActive : typeFilterBtnIdle),
|
||||
}}
|
||||
>
|
||||
{t === "all" ? "All" : ENTITY_TYPE_LABELS[t] || t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results + detail */}
|
||||
<div style={searchBodyStyle}>
|
||||
<div style={resultListStyle}>
|
||||
{!query && (
|
||||
<div style={hintStyle}>
|
||||
<Search size={20} color="rgba(74,163,255,0.2)" />
|
||||
<span style={{ color: "#6a7f97", fontSize: 12, marginTop: 8 }}>
|
||||
Type to search across all loaded ontologies
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query && results.length === 0 && !searching && (
|
||||
<div style={hintStyle}>
|
||||
<span style={{ color: "#6a7f97", fontSize: 12 }}>No results for "{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div style={{ padding: "10px 12px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 11, fontWeight: 700, marginBottom: 2 }}>
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
{results.map((r) => (
|
||||
<ResultRow
|
||||
key={r.uri}
|
||||
result={r}
|
||||
selected={selectedUri === r.uri}
|
||||
onSelect={() => setSelectedUri((prev) => (prev === r.uri ? null : r.uri))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUri && (
|
||||
<DetailPanel uri={selectedUri} onClose={() => setSelectedUri(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const searchShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const searchTopStyle: React.CSSProperties = {
|
||||
padding: "12px 14px 10px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const searchBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "7px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.14)",
|
||||
background: "rgba(0,0,0,0.24)",
|
||||
};
|
||||
|
||||
const searchInputStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const clearBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#6a7f97",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const typeFilterStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 5,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const typeFilterBtnBase: React.CSSProperties = {
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const typeFilterBtnIdle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const typeFilterBtnActive: React.CSSProperties = {
|
||||
background: "rgba(74,163,255,0.14)",
|
||||
color: "#ebf3ff",
|
||||
borderColor: "rgba(127,208,255,0.26)",
|
||||
};
|
||||
|
||||
const searchBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const resultListStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
const hintStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 32,
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(3,9,18,0.5)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const detailHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 14px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeDetailBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 24,
|
||||
};
|
||||
|
||||
const collapseHdrStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
padding: "6px 0",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
};
|
||||
|
||||
const openUriStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
marginTop: 14,
|
||||
color: "#58a6ff",
|
||||
fontSize: 11,
|
||||
textDecoration: "none",
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
GitMerge,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Send,
|
||||
MessageSquare,
|
||||
User,
|
||||
Clock,
|
||||
Plus,
|
||||
Minus,
|
||||
Edit,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Proposal {
|
||||
proposal_id: string;
|
||||
draft_id: string;
|
||||
ontology_uri: string;
|
||||
summary: string;
|
||||
author: string;
|
||||
reviewer: string | null;
|
||||
state: "draft" | "proposed" | "approved" | "published" | "rejected";
|
||||
impact_analysis: Record<string, any>;
|
||||
shacl_validation: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
comments: Record<string, any>[];
|
||||
}
|
||||
|
||||
interface DiffChange {
|
||||
type: "added" | "removed" | "modified";
|
||||
element: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function ProposalReview({ proposalId }: { proposalId: string }) {
|
||||
const [proposal, setProposal] = useState<Proposal | null>(null);
|
||||
const [diff, setDiff] = useState<DiffChange[]>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
|
||||
const loadProposal = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposalId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProposal(data);
|
||||
generateDiff(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load proposal:", error);
|
||||
}
|
||||
}, [proposalId]);
|
||||
|
||||
const generateDiff = useCallback((prop: Proposal) => {
|
||||
const changes: DiffChange[] = [];
|
||||
|
||||
// Generate diff from impact analysis
|
||||
if (prop.impact_analysis) {
|
||||
if (prop.impact_analysis.class_adds > 0) {
|
||||
changes.push({ type: "added", element: `Classes (${prop.impact_analysis.class_adds})` });
|
||||
}
|
||||
if (prop.impact_analysis.class_removals > 0) {
|
||||
changes.push({ type: "removed", element: `Classes (${prop.impact_analysis.class_removals})` });
|
||||
}
|
||||
if (prop.impact_analysis.property_changes > 0) {
|
||||
changes.push({ type: "modified", element: `Properties (${prop.impact_analysis.property_changes})` });
|
||||
}
|
||||
if (prop.impact_analysis.restriction_changes > 0) {
|
||||
changes.push({ type: "modified", element: `Restrictions (${prop.impact_analysis.restriction_changes})` });
|
||||
}
|
||||
}
|
||||
|
||||
setDiff(changes);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadProposal();
|
||||
}, [loadProposal]);
|
||||
|
||||
const addComment = useCallback(async () => {
|
||||
if (!selectedElement || !commentText || !proposal) return;
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/comment`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
element_uri: selectedElement,
|
||||
text: commentText,
|
||||
author: "user",
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
setCommentText("");
|
||||
loadProposal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add comment:", error);
|
||||
alert("Failed to add comment");
|
||||
}
|
||||
}, [selectedElement, commentText, proposal, loadProposal]);
|
||||
|
||||
const approveProposal = useCallback(async () => {
|
||||
if (!proposal) return;
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal approved");
|
||||
loadProposal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve proposal:", error);
|
||||
alert("Failed to approve proposal");
|
||||
}
|
||||
}, [proposal, loadProposal]);
|
||||
|
||||
const rejectProposal = useCallback(async () => {
|
||||
if (!proposal) return;
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/reject`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal rejected");
|
||||
loadProposal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reject proposal:", error);
|
||||
alert("Failed to reject proposal");
|
||||
}
|
||||
}, [proposal, loadProposal]);
|
||||
|
||||
const publishProposal = useCallback(async () => {
|
||||
if (!proposal) return;
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposal.proposal_id}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal published");
|
||||
loadProposal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to publish proposal:", error);
|
||||
alert("Failed to publish proposal");
|
||||
}
|
||||
}, [proposal, loadProposal]);
|
||||
|
||||
const getChangeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "added":
|
||||
return <Plus size={14} color="#4cc38a" />;
|
||||
case "removed":
|
||||
return <Minus size={14} color="#ff6b6b" />;
|
||||
case "modified":
|
||||
return <Edit size={14} color="#f2b66d" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStateIcon = (state: string) => {
|
||||
switch (state) {
|
||||
case "published":
|
||||
return <CheckCircle size={20} color="#4cc38a" />;
|
||||
case "approved":
|
||||
return <CheckCircle size={20} color="#4aa3ff" />;
|
||||
case "rejected":
|
||||
return <XCircle size={20} color="#ff6b6b" />;
|
||||
case "proposed":
|
||||
return <AlertCircle size={20} color="#f2b66d" />;
|
||||
default:
|
||||
return <Clock size={20} color="#8fa8c6" />;
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
background: "#07111f",
|
||||
padding: "20px",
|
||||
overflow: "auto",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
paddingBottom: "16px",
|
||||
borderBottom: "1px solid rgba(140, 192, 255, 0.12)",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#ebf3ff",
|
||||
fontSize: "20px",
|
||||
fontWeight: "700",
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
const diffPanelStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
background: "rgba(9, 19, 34, 0.8)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.12)",
|
||||
padding: "16px",
|
||||
overflow: "auto",
|
||||
};
|
||||
|
||||
const commentsPanelStyle: React.CSSProperties = {
|
||||
width: "320px",
|
||||
background: "rgba(9, 19, 34, 0.8)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.12)",
|
||||
padding: "16px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
};
|
||||
|
||||
const diffItemStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "6px",
|
||||
background: "rgba(3, 9, 18, 0.6)",
|
||||
marginBottom: "8px",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
background: "rgba(74, 163, 255, 0.1)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
background: "rgba(3, 9, 18, 0.8)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "13px",
|
||||
resize: "vertical",
|
||||
minHeight: "80px",
|
||||
};
|
||||
|
||||
if (!proposal) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "14px" }}>Loading proposal...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
{getStateIcon(proposal.state)}
|
||||
<div>
|
||||
<h1 style={titleStyle}>{proposal.summary}</h1>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "12px", marginTop: "4px" }}>
|
||||
{proposal.author} • {new Date(proposal.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{proposal.state === "proposed" && (
|
||||
<>
|
||||
<button style={buttonStyle} onClick={approveProposal}>
|
||||
<CheckCircle size={12} />
|
||||
Approve
|
||||
</button>
|
||||
<button style={buttonStyle} onClick={rejectProposal}>
|
||||
<XCircle size={12} />
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{proposal.state === "approved" && (
|
||||
<button style={buttonStyle} onClick={publishProposal}>
|
||||
<Send size={12} />
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={contentStyle}>
|
||||
<div style={diffPanelStyle}>
|
||||
<h2 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "14px", fontWeight: "600" }}>
|
||||
<GitMerge size={16} style={{ marginRight: "8px", verticalAlign: "middle" }} />
|
||||
Diff Viewer
|
||||
</h2>
|
||||
{diff.length === 0 ? (
|
||||
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No changes detected</div>
|
||||
) : (
|
||||
diff.map((change, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
...diffItemStyle,
|
||||
border: selectedElement === change.element ? "1px solid rgba(74, 163, 255, 0.4)" : "1px solid transparent",
|
||||
}}
|
||||
onClick={() => setSelectedElement(change.element)}
|
||||
>
|
||||
{getChangeIcon(change.type)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
|
||||
{change.element}
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
|
||||
{change.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "20px", paddingTop: "16px", borderTop: "1px solid rgba(140, 192, 255, 0.12)" }}>
|
||||
<h3 style={{ margin: "0 0 12px", color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
|
||||
Impact Analysis
|
||||
</h3>
|
||||
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
|
||||
{JSON.stringify(proposal.impact_analysis, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<h3 style={{ margin: "0 0 12px", color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
|
||||
SHACL Validation
|
||||
</h3>
|
||||
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
|
||||
{JSON.stringify(proposal.shacl_validation, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={commentsPanelStyle}>
|
||||
<h2 style={{ margin: "0 0 16px", color: "#ebf3ff", fontSize: "14px", fontWeight: "600" }}>
|
||||
<MessageSquare size={16} style={{ marginRight: "8px", verticalAlign: "middle" }} />
|
||||
Comments ({proposal.comments.length})
|
||||
</h2>
|
||||
<div style={{ flex: 1, overflow: "auto", marginBottom: "12px" }}>
|
||||
{proposal.comments.length === 0 ? (
|
||||
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No comments yet</div>
|
||||
) : (
|
||||
proposal.comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "rgba(3, 9, 18, 0.6)",
|
||||
borderRadius: "6px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
|
||||
<User size={12} color="#8fa8c6" />
|
||||
<span style={{ color: "#ebf3ff", fontSize: "12px", fontWeight: "600" }}>
|
||||
{comment.author}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>{comment.text}</div>
|
||||
<div style={{ color: "#5a7a9a", fontSize: "10px" }}>
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{selectedElement && (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
<button style={buttonStyle} onClick={addComment} disabled={!commentText}>
|
||||
<Send size={12} />
|
||||
Add Comment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SKOSScheme {
|
||||
uri: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
concept_count: number;
|
||||
}
|
||||
|
||||
interface ConceptNode {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels?: string[];
|
||||
description?: string;
|
||||
notation?: string;
|
||||
scheme_uri?: string;
|
||||
parent_uri?: string;
|
||||
children?: ConceptNode[];
|
||||
}
|
||||
|
||||
interface SKOSConceptDetail {
|
||||
uri: string;
|
||||
pref_label: string;
|
||||
alt_labels: string[];
|
||||
hidden_labels: string[];
|
||||
definition?: string;
|
||||
scope_note?: string;
|
||||
editorial_note?: string;
|
||||
broader: string[];
|
||||
narrower: string[];
|
||||
related: string[];
|
||||
exact_match: string[];
|
||||
close_match: string[];
|
||||
broad_match: string[];
|
||||
narrow_match: string[];
|
||||
scheme_uri?: string;
|
||||
}
|
||||
|
||||
function countConcepts(nodes: ConceptNode[]): number {
|
||||
return nodes.reduce((acc, n) => acc + 1 + countConcepts(n.children ?? []), 0);
|
||||
}
|
||||
|
||||
function LabelChip({ label }: { label: string }) {
|
||||
return (
|
||||
<span style={chipStyle}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UriLink({ uri }: { uri: string }) {
|
||||
const short = uri.includes("#") ? uri.split("#").pop() : uri.split("/").pop();
|
||||
return (
|
||||
<span title={uri} style={{ color: "#58a6ff", fontSize: 11, fontFamily: "monospace", cursor: "help" }}>
|
||||
{short || uri}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConceptDetailPanel({
|
||||
uri,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
uri: string;
|
||||
onClose: () => void;
|
||||
onNavigate: (uri: string) => void;
|
||||
}) {
|
||||
const [detail, setDetail] = useState<SKOSConceptDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetch(`/api/ontology/skos/concept/${encodeURIComponent(uri)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Concept not found");
|
||||
return r.json();
|
||||
})
|
||||
.then(setDetail)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [uri]);
|
||||
|
||||
const renderUriList = (label: string, uris: string[]) => {
|
||||
if (!uris.length) return null;
|
||||
return (
|
||||
<PropSection label={label}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{uris.map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => onNavigate(u)}
|
||||
style={navLinkStyle}
|
||||
>
|
||||
<ChevronRight size={10} />
|
||||
<UriLink uri={u} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PropSection>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={detailPanelStyle}>
|
||||
<div style={detailHeaderStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<BookOpen size={13} color="#9ee8d7" />
|
||||
<span style={{ color: "#ebf3ff", fontSize: 13, fontWeight: 700 }}>Concept Detail</span>
|
||||
</div>
|
||||
<button onClick={onClose} style={iconBtnStyle}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && !loading && (
|
||||
<div style={detailBodyStyle}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<h3 style={{ margin: "0 0 4px", color: "#ebf3ff", fontSize: 17, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
{detail.pref_label}
|
||||
</h3>
|
||||
{detail.alt_labels.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
|
||||
{detail.alt_labels.map((l) => <LabelChip key={l} label={l} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.hidden_labels.length > 0 && (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 6 }}>
|
||||
{detail.hidden_labels.map((l) => (
|
||||
<span key={l} style={{ ...chipStyle, opacity: 0.5, fontStyle: "italic" }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "#5a7a9a", fontSize: 10, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{uri}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.definition && (
|
||||
<PropSection label="Definition">
|
||||
<p style={{ margin: 0, color: "#c6d4e3", fontSize: 13, lineHeight: 1.6 }}>
|
||||
{detail.definition}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{detail.scope_note && (
|
||||
<PropSection label="Scope Note">
|
||||
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
|
||||
{detail.scope_note}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{detail.editorial_note && (
|
||||
<PropSection label="Editorial Note">
|
||||
<p style={{ margin: 0, color: "#8fa8c6", fontSize: 12, lineHeight: 1.5 }}>
|
||||
{detail.editorial_note}
|
||||
</p>
|
||||
</PropSection>
|
||||
)}
|
||||
|
||||
{renderUriList("Broader", detail.broader)}
|
||||
{renderUriList("Narrower", detail.narrower)}
|
||||
{renderUriList("Related", detail.related)}
|
||||
{renderUriList("Exact Match", detail.exact_match)}
|
||||
{renderUriList("Close Match", detail.close_match)}
|
||||
{renderUriList("Broad Match", detail.broad_match)}
|
||||
{renderUriList("Narrow Match", detail.narrow_match)}
|
||||
|
||||
{detail.scheme_uri && (
|
||||
<PropSection label="Concept Scheme">
|
||||
<span style={{ color: "#c6d4e3", fontSize: 11, fontFamily: "monospace", wordBreak: "break-all" }}>
|
||||
{detail.scheme_uri}
|
||||
</span>
|
||||
</PropSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropSection({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ paddingTop: 10, borderTop: "1px solid rgba(255,255,255,0.05)", marginTop: 10 }}>
|
||||
<div style={{ color: "#6a7f97", fontSize: 10, fontWeight: 700, textTransform: "uppercase" as const, letterSpacing: "0.07em", marginBottom: 5 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concept tree node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ConceptTreeNode({
|
||||
concept,
|
||||
depth,
|
||||
selectedUri,
|
||||
onSelect,
|
||||
}: {
|
||||
concept: ConceptNode;
|
||||
depth: number;
|
||||
selectedUri: string | null;
|
||||
onSelect: (uri: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const children = concept.children ?? [];
|
||||
const hasChildren = children.length > 0;
|
||||
const isSelected = selectedUri === concept.uri;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingLeft: 10 + depth * 14,
|
||||
paddingRight: 10,
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
borderRadius: 7,
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(74,163,255,0.12)" : "transparent",
|
||||
transition: "120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
(e.currentTarget as HTMLDivElement).style.background = "rgba(74,163,255,0.06)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
(e.currentTarget as HTMLDivElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
|
||||
style={expandBtnStyle}
|
||||
>
|
||||
{expanded ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ width: 18, display: "inline-block", flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<span
|
||||
onClick={() => onSelect(concept.uri)}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: isSelected ? "#ebf3ff" : depth === 0 ? "#c6d4e3" : "#8fa8c6",
|
||||
fontSize: depth === 0 ? 13 : 12,
|
||||
fontWeight: depth === 0 ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{concept.pref_label || concept.uri}
|
||||
</span>
|
||||
|
||||
{hasChildren && (
|
||||
<span style={{ color: "#5a7a9a", fontSize: 10, flexShrink: 0 }}>
|
||||
{children.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && hasChildren && children.map((child) => (
|
||||
<ConceptTreeNode
|
||||
key={child.uri}
|
||||
concept={child}
|
||||
depth={depth + 1}
|
||||
selectedUri={selectedUri}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheme panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SchemePanel({
|
||||
scheme,
|
||||
selectedUri,
|
||||
onSelectConcept,
|
||||
searchQuery,
|
||||
}: {
|
||||
scheme: SKOSScheme;
|
||||
selectedUri: string | null;
|
||||
onSelectConcept: (uri: string) => void;
|
||||
searchQuery: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [hierarchy, setHierarchy] = useState<ConceptNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/vocabulary/hierarchy?scheme=${encodeURIComponent(scheme.uri)}`)
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setHierarchy)
|
||||
.catch(() => setHierarchy([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [scheme.uri, expanded]);
|
||||
|
||||
const totalConcepts = countConcepts(hierarchy);
|
||||
|
||||
const filterConcepts = (nodes: ConceptNode[], q: string): ConceptNode[] => {
|
||||
if (!q) return nodes;
|
||||
return nodes.flatMap((n) => {
|
||||
const match = (n.pref_label + " " + (n.alt_labels?.join(" ") ?? "") + " " + (n.description ?? ""))
|
||||
.toLowerCase()
|
||||
.includes(q.toLowerCase());
|
||||
const filteredChildren = filterConcepts(n.children ?? [], q);
|
||||
if (match || filteredChildren.length > 0) {
|
||||
return [{ ...n, children: filteredChildren }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const displayedConcepts = filterConcepts(hierarchy, searchQuery);
|
||||
|
||||
return (
|
||||
<div style={schemePanelStyle}>
|
||||
<button onClick={() => setExpanded((v) => !v)} style={schemeHeaderBtnStyle}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{expanded ? <ChevronDown size={13} color="#8fa8c6" /> : <ChevronRight size={13} color="#8fa8c6" />}
|
||||
<span style={{ color: "#e6edf3", fontSize: 14, fontWeight: 700 }}>{scheme.title}</span>
|
||||
</div>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11 }}>
|
||||
{loading ? "…" : `${totalConcepts} concept${totalConcepts !== 1 ? "s" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 20px", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Loader2 size={12} color="#4aa3ff" style={{ animation: "spin 0.8s linear infinite" }} />
|
||||
<span style={{ color: "#6a7f97", fontSize: 12 }}>Loading concepts…</span>
|
||||
</div>
|
||||
) : displayedConcepts.length === 0 ? (
|
||||
<div style={{ padding: "8px 24px", color: "#6a7f97", fontSize: 12, fontStyle: "italic" }}>
|
||||
{searchQuery ? "No matching concepts" : "No concepts in this scheme"}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ paddingTop: 2 }}>
|
||||
{displayedConcepts.map((concept) => (
|
||||
<ConceptTreeNode
|
||||
key={concept.uri}
|
||||
concept={concept}
|
||||
depth={0}
|
||||
selectedUri={selectedUri}
|
||||
onSelect={onSelectConcept}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main SKOSVocabularyManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Props {
|
||||
schemeUri?: string;
|
||||
}
|
||||
|
||||
export function SKOSVocabularyManager({ schemeUri }: Props) {
|
||||
const [schemes, setSchemes] = useState<SKOSScheme[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQ, setSearchQ] = useState("");
|
||||
const [selectedUri, setSelectedUri] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/ontology/skos/schemes")
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then(setSchemes)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const displayedSchemes = schemeUri
|
||||
? schemes.filter((s) => s.uri === schemeUri)
|
||||
: schemes;
|
||||
|
||||
return (
|
||||
<div style={managerShellStyle}>
|
||||
{/* Search bar */}
|
||||
<div style={skosToolbarStyle}>
|
||||
<div style={skosSearchBarStyle}>
|
||||
<Search size={13} color="#6a7f97" />
|
||||
<input
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder="Search labels and definitions…"
|
||||
style={skosSearchInputStyle}
|
||||
/>
|
||||
{searchQ && (
|
||||
<button onClick={() => setSearchQ("")} style={iconBtnStyle}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={skosBodyStyle}>
|
||||
{/* Scheme tree column */}
|
||||
<div style={treeColStyle}>
|
||||
{loading && (
|
||||
<div style={centerStyle}>
|
||||
<Loader2 size={18} color="#4aa3ff" style={{ animation: "spin 1s linear infinite" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={centerStyle}>
|
||||
<AlertCircle size={16} color="#ff9daf" />
|
||||
<span style={{ color: "#ff9daf", fontSize: 12, marginTop: 6 }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && displayedSchemes.length === 0 && (
|
||||
<div style={{ ...centerStyle, textAlign: "center", padding: 28 }}>
|
||||
<BookOpen size={28} color="rgba(158,232,215,0.15)" />
|
||||
<span style={{ color: "#8fa8c6", fontSize: 12, marginTop: 10 }}>
|
||||
No SKOS concept schemes found
|
||||
</span>
|
||||
<span style={{ color: "#6a7f97", fontSize: 11, marginTop: 4, maxWidth: 220 }}>
|
||||
Import a SKOS vocabulary to browse concepts here
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && displayedSchemes.map((scheme) => (
|
||||
<SchemePanel
|
||||
key={scheme.uri}
|
||||
scheme={scheme}
|
||||
selectedUri={selectedUri}
|
||||
onSelectConcept={setSelectedUri}
|
||||
searchQuery={searchQ}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Concept detail panel */}
|
||||
{selectedUri && (
|
||||
<ConceptDetailPanel
|
||||
uri={selectedUri}
|
||||
onClose={() => setSelectedUri(null)}
|
||||
onNavigate={setSelectedUri}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const managerShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const skosToolbarStyle: React.CSSProperties = {
|
||||
padding: "10px 12px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const skosSearchBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "6px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(127,208,255,0.12)",
|
||||
background: "rgba(0,0,0,0.22)",
|
||||
};
|
||||
|
||||
const skosSearchInputStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "#ebf3ff",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const skosBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const treeColStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "8px 6px",
|
||||
};
|
||||
|
||||
const detailPanelStyle: React.CSSProperties = {
|
||||
width: 300,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderLeft: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(3,9,18,0.5)",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const detailHeaderStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 14px",
|
||||
borderBottom: "1px solid rgba(127,208,255,0.08)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const detailBodyStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "14px",
|
||||
};
|
||||
|
||||
const schemePanelStyle: React.CSSProperties = {
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(127,208,255,0.1)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
overflow: "hidden",
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const schemeHeaderBtnStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 12px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const expandBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
width: 18,
|
||||
};
|
||||
|
||||
const chipStyle: React.CSSProperties = {
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "#8fa8c6",
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "#8fa8c6",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
};
|
||||
|
||||
const navLinkStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "2px 0",
|
||||
textAlign: "left",
|
||||
};
|
||||
|
||||
const centerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
padding: 24,
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import Editor, { type Monaco } from "@monaco-editor/react";
|
||||
import { FileCode2, Loader2, Play, Shield, Wand2 } from "lucide-react";
|
||||
import {
|
||||
generateShacl,
|
||||
loadOntologyRegistry,
|
||||
loadShaclShapes,
|
||||
validateShacl,
|
||||
} from "./api";
|
||||
import type { OntologyEntry, ShaclShapeSummary, ShaclValidationResponse } from "./types";
|
||||
|
||||
interface ShaclStudioProps {
|
||||
onJumpToNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function ShaclStudio({ onJumpToNode }: ShaclStudioProps) {
|
||||
const [registry, setRegistry] = useState<OntologyEntry[]>([]);
|
||||
const [selectedUri, setSelectedUri] = useState("");
|
||||
const [shacl, setShacl] = useState("");
|
||||
const [fullShacl, setFullShacl] = useState(""); // preserves complete Turtle across shape selections
|
||||
const [shapes, setShapes] = useState<ShaclShapeSummary[]>([]);
|
||||
const [selectedShapeId, setSelectedShapeId] = useState<string | null>(null);
|
||||
const [validation, setValidation] = useState<ShaclValidationResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOntologyRegistry()
|
||||
.then((entries) => {
|
||||
if (cancelled) return;
|
||||
setRegistry(entries);
|
||||
setSelectedUri((current) => current || entries[0]?.uri || "");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : "Could not load ontology registry.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadShapes = useCallback(async (uri: string) => {
|
||||
if (!uri) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await loadShaclShapes(uri);
|
||||
setShapes(data.shapes);
|
||||
const turtle = data.shacl_turtle;
|
||||
setFullShacl(turtle);
|
||||
setShacl((current) => current || turtle);
|
||||
setSelectedShapeId(null);
|
||||
setValidation(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load SHACL shapes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShacl("");
|
||||
setFullShacl("");
|
||||
setSelectedShapeId(null);
|
||||
void loadShapes(selectedUri);
|
||||
}, [selectedUri, loadShapes]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!selectedUri) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await generateShacl(selectedUri, "strict");
|
||||
setFullShacl(data.shacl_turtle);
|
||||
setShacl(data.shacl_turtle);
|
||||
setSelectedShapeId(null);
|
||||
const shapeData = await loadShaclShapes(selectedUri);
|
||||
setShapes(shapeData.shapes);
|
||||
setValidation(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not generate SHACL.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedUri]);
|
||||
|
||||
const handleSelectShape = useCallback((shapeId: string) => {
|
||||
setSelectedShapeId(shapeId);
|
||||
// Extract the Turtle block for this shape from the full SHACL so the editor
|
||||
// pre-populates with the selected shape's definition.
|
||||
const src = fullShacl || shacl;
|
||||
const normalised = src.replace(/\r\n/g, "\n");
|
||||
// Split on blank lines to isolate statement groups.
|
||||
const blocks = normalised.split(/\n{2,}/).filter((b) => b.trim());
|
||||
const match = blocks.find((b) => {
|
||||
const first = b.trimStart();
|
||||
return first.startsWith(shapeId + " ") || first.startsWith(shapeId + "\n") || first.startsWith(shapeId + "\t");
|
||||
});
|
||||
if (match) {
|
||||
setShacl(match.trim());
|
||||
}
|
||||
}, [fullShacl, shacl]);
|
||||
|
||||
const handleShowAllShapes = useCallback(() => {
|
||||
setSelectedShapeId(null);
|
||||
setShacl(fullShacl);
|
||||
}, [fullShacl]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedUri || !shacl.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setValidation(await validateShacl(selectedUri, shacl));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not validate SHACL.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupedShapes = useMemo(() => {
|
||||
const groups = new Map<string, ShaclShapeSummary[]>();
|
||||
for (const shape of shapes) {
|
||||
const key = shape.target_class || "Untargeted shapes";
|
||||
groups.set(key, [...(groups.get(key) || []), shape]);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
}, [shapes]);
|
||||
|
||||
const beforeMount = useCallback((monaco: Monaco) => {
|
||||
if (!monaco.languages.getLanguages().some((language: { id: string }) => language.id === "turtle")) {
|
||||
monaco.languages.register({ id: "turtle", extensions: [".ttl"], mimetypes: ["text/turtle"] });
|
||||
monaco.languages.setMonarchTokensProvider("turtle", {
|
||||
keywords: ["@prefix", "@base", "a"],
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#[^\n]*/, "comment"],
|
||||
[/"(?:[^"\\]|\\.)*"(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/'(?:[^'\\]|\\.)*'(?:@[a-zA-Z-]+|\\^\\^[^\s,;.]+)?/, "string"],
|
||||
[/"""[\s\S]*?"""/, "string"],
|
||||
[/<[^>]*>/, "type.identifier"],
|
||||
[/\b(?:@prefix|@base|a)\b/, "keyword"],
|
||||
[/\b(?:sh|xsd|owl|rdf|rdfs|skos):[\w]+/, "variable"],
|
||||
[/[a-zA-Z_][\w-]*:[\w]+/, "namespace"],
|
||||
[/[;,.]/, "delimiter"],
|
||||
[/\d+(?:\.\d+)?/, "number"],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
monaco.editor.defineTheme("shacl-dark", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "keyword", foreground: "9ee8d7" },
|
||||
{ token: "string", foreground: "f2b66d" },
|
||||
{ token: "comment", foreground: "4a6070", fontStyle: "italic" },
|
||||
{ token: "type.identifier", foreground: "7ce7d3" },
|
||||
{ token: "variable", foreground: "d2a8ff" },
|
||||
{ token: "namespace", foreground: "a5d6ff" },
|
||||
{ token: "number", foreground: "79c0ff" },
|
||||
{ token: "delimiter", foreground: "8fa8c6" },
|
||||
],
|
||||
colors: {
|
||||
"editor.background": "#050b13",
|
||||
"editor.foreground": "#d7e7f8",
|
||||
"editorLineNumber.foreground": "#41536b",
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<section style={heroStyle}>
|
||||
<div>
|
||||
<div style={kickerStyle}><Shield size={14} /> SHACL Studio</div>
|
||||
<h2 style={titleStyle}>Generate, edit, and validate shapes</h2>
|
||||
<p style={textStyle}>
|
||||
Create strict SHACL Turtle from ontology structure, inspect shape targets,
|
||||
run validation, and jump from violations back into the graph.
|
||||
</p>
|
||||
</div>
|
||||
<div style={selectorShellStyle}>
|
||||
<label style={labelStyle}>Ontology</label>
|
||||
<select style={inputStyle} value={selectedUri} onChange={(event) => setSelectedUri(event.target.value)}>
|
||||
{registry.map((entry) => <option key={entry.uri} value={entry.uri}>{entry.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <div style={errorStyle}>{error}</div> : null}
|
||||
|
||||
<div style={gridStyle}>
|
||||
<section style={cardStyle}>
|
||||
<div style={panelHeaderStyle}>
|
||||
<h3 style={sectionTitleStyle}>Shape library</h3>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<span style={countBadgeStyle}>{shapes.length} shapes</span>
|
||||
{selectedShapeId ? (
|
||||
<button style={smallButtonStyle} onClick={handleShowAllShapes}>View all</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={shapeListStyle}>
|
||||
{groupedShapes.map(([target, items]) => (
|
||||
<div key={target} style={shapeGroupStyle}>
|
||||
<div style={shapeTargetStyle}>{target}</div>
|
||||
{items.map((shape) => {
|
||||
const isSelected = selectedShapeId === shape.id;
|
||||
return (
|
||||
<button
|
||||
key={shape.id}
|
||||
style={{
|
||||
...shapeRowStyle,
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "rgba(124,231,211,0.1)" : "rgba(255,255,255,0.03)",
|
||||
border: isSelected ? "1px solid rgba(124,231,211,0.35)" : "1px solid rgba(127,208,255,0.08)",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
}}
|
||||
onClick={() => handleSelectShape(shape.id)}
|
||||
title="Click to load this shape into the editor"
|
||||
>
|
||||
<FileCode2 size={14} color={isSelected ? "#7ce7d3" : "#9ee8d7"} />
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{shape.id}</div>
|
||||
<div style={mutedStyle}>
|
||||
{shape.constraint_count} constraints
|
||||
{shape.constraints.length ? ` · ${shape.constraints.join(", ")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span style={violationBadgeStyle}>{shape.violation_count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{!shapes.length ? <p style={mutedStyle}>No shapes generated yet.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={editorShellStyle}>
|
||||
<div style={panelHeaderStyle}>
|
||||
<h3 style={sectionTitleStyle}>
|
||||
{selectedShapeId ? selectedShapeId : "Turtle shape editor"}
|
||||
</h3>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={secondaryButtonStyle} disabled={loading} onClick={handleGenerate}><Wand2 size={14} /> Generate strict</button>
|
||||
<button style={primaryButtonStyle} disabled={loading || !shacl.trim()} onClick={handleValidate}>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
|
||||
Validate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={editorFrameStyle}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="turtle"
|
||||
theme="shacl-dark"
|
||||
beforeMount={beforeMount}
|
||||
value={shacl}
|
||||
onChange={(value) => setShacl(value || "")}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: "JetBrains Mono, monospace",
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<div style={panelHeaderStyle}>
|
||||
<h3 style={sectionTitleStyle}>Validation report</h3>
|
||||
{validation ? <span style={validationBadgeStyle(validation.status, validation.conforms)}>{validation.status}{validation.conforms ? " · conforms" : ""}</span> : null}
|
||||
</div>
|
||||
{validation ? (
|
||||
<>
|
||||
<p style={textStyle}>{validation.message}</p>
|
||||
<div style={shapeListStyle}>
|
||||
{validation.violations.map((violation, index) => {
|
||||
const nodeId = violation.focus_node || violation.node;
|
||||
return (
|
||||
<div key={`${violation.node}-${violation.path}-${index}`} style={violationRowStyle}>
|
||||
<div>
|
||||
<div style={{ color: "#ebf3ff", fontWeight: 800 }}>{violation.message}</div>
|
||||
<div style={mutedStyle}>{violation.severity} {violation.path ? `· ${violation.path}` : ""}</div>
|
||||
{nodeId ? <div style={monoStyle}>{nodeId}</div> : null}
|
||||
</div>
|
||||
{nodeId ? (
|
||||
<button style={smallButtonStyle} onClick={() => onJumpToNode?.(nodeId)}>
|
||||
Jump to Node
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!validation.violations.length ? <p style={mutedStyle}>No validation violations returned.</p> : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p style={mutedStyle}>Generate or edit SHACL Turtle, then run validation.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function validationBadgeStyle(status: string, conforms: boolean): CSSProperties {
|
||||
const color = status === "unavailable" ? "#f2b66d" : conforms ? "#7ce7d3" : "#ff9daf";
|
||||
return { color, border: `1px solid ${color}35`, background: `${color}14`, borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900, textTransform: "uppercase" };
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = { height: "100%", overflow: "auto", padding: 22, display: "flex", flexDirection: "column", gap: 16 };
|
||||
const heroStyle: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 18, padding: 22, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 22, background: "linear-gradient(135deg, rgba(11,25,42,0.94), rgba(7,14,25,0.9))" };
|
||||
const kickerStyle: CSSProperties = { display: "inline-flex", gap: 8, alignItems: "center", color: "#9ee8d7", fontSize: 11, fontWeight: 900, letterSpacing: "0.12em", textTransform: "uppercase" };
|
||||
const titleStyle: CSSProperties = { margin: "8px 0", color: "#ebf3ff", fontSize: 26, letterSpacing: "-0.04em" };
|
||||
const textStyle: CSSProperties = { margin: 0, color: "#8fa8c6", lineHeight: 1.6, maxWidth: 680 };
|
||||
const selectorShellStyle: CSSProperties = { minWidth: 320 };
|
||||
const labelStyle: CSSProperties = { display: "block", color: "#6a7f97", fontSize: 11, fontWeight: 800, margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.08em" };
|
||||
const inputStyle: CSSProperties = { width: "100%", boxSizing: "border-box", border: "1px solid rgba(127,208,255,0.14)", borderRadius: 12, padding: "10px 12px", background: "rgba(3,9,18,0.8)", color: "#ebf3ff" };
|
||||
const gridStyle: CSSProperties = { display: "grid", gridTemplateColumns: "360px minmax(0, 1fr)", gap: 16, minHeight: 560 };
|
||||
const cardStyle: CSSProperties = { padding: 18, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 20, background: "rgba(9,19,34,0.78)" };
|
||||
const editorShellStyle: CSSProperties = { ...cardStyle, display: "flex", flexDirection: "column", minHeight: 560 };
|
||||
const panelHeaderStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 12 };
|
||||
const sectionTitleStyle: CSSProperties = { margin: 0, color: "#ebf3ff", fontSize: 16 };
|
||||
const countBadgeStyle: CSSProperties = { color: "#9ee8d7", border: "1px solid rgba(158,232,215,0.2)", borderRadius: 999, padding: "4px 9px", fontSize: 11, fontWeight: 900 };
|
||||
const shapeListStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 10 };
|
||||
const shapeGroupStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 6 };
|
||||
const shapeTargetStyle: CSSProperties = { color: "#6a7f97", fontSize: 11, fontWeight: 900, textTransform: "uppercase", letterSpacing: "0.08em" };
|
||||
const shapeRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "18px 1fr auto", gap: 10, padding: 10, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
|
||||
const violationBadgeStyle: CSSProperties = { color: "#f2b66d", fontWeight: 900 };
|
||||
const editorFrameStyle: CSSProperties = { flex: 1, minHeight: 0, border: "1px solid rgba(127,208,255,0.12)", borderRadius: 16, overflow: "hidden" };
|
||||
const primaryButtonStyle: CSSProperties = { border: "1px solid rgba(124,231,211,0.35)", borderRadius: 12, padding: "9px 11px", background: "linear-gradient(135deg, rgba(20,151,136,0.55), rgba(74,163,255,0.35))", color: "#ebf3ff", fontWeight: 900, cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 8 };
|
||||
const secondaryButtonStyle: CSSProperties = { ...primaryButtonStyle, background: "rgba(127,208,255,0.08)", borderColor: "rgba(127,208,255,0.18)" };
|
||||
const violationRowStyle: CSSProperties = { display: "grid", gridTemplateColumns: "1fr auto", gap: 12, padding: 12, borderRadius: 14, background: "rgba(255,255,255,0.03)", border: "1px solid rgba(127,208,255,0.08)" };
|
||||
const smallButtonStyle: CSSProperties = { border: "1px solid rgba(127,208,255,0.16)", borderRadius: 10, padding: "7px 9px", background: "rgba(127,208,255,0.08)", color: "#ebf3ff", cursor: "pointer", fontWeight: 800 };
|
||||
const monoStyle: CSSProperties = { marginTop: 4, color: "#6a7f97", fontSize: 11, fontFamily: "JetBrains Mono, monospace", wordBreak: "break-all" };
|
||||
const mutedStyle: CSSProperties = { margin: 0, color: "#6a7f97", fontSize: 12, lineHeight: 1.5 };
|
||||
const errorStyle: CSSProperties = { padding: 12, borderRadius: 14, color: "#ffb4c2", background: "rgba(255,157,175,0.1)", border: "1px solid rgba(255,157,175,0.18)" };
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Layers,
|
||||
GitMerge,
|
||||
Clock,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Send,
|
||||
X,
|
||||
ArrowRight,
|
||||
Scale,
|
||||
} from "lucide-react";
|
||||
|
||||
interface VersionEntry {
|
||||
version_id: string;
|
||||
ontology_uri: string;
|
||||
state: "draft" | "published";
|
||||
author: string;
|
||||
date: string;
|
||||
diff_summary: Record<string, any>;
|
||||
}
|
||||
|
||||
interface Proposal {
|
||||
proposal_id: string;
|
||||
draft_id: string;
|
||||
ontology_uri: string;
|
||||
summary: string;
|
||||
author: string;
|
||||
reviewer: string | null;
|
||||
state: "draft" | "proposed" | "approved" | "published" | "rejected";
|
||||
impact_analysis: Record<string, any>;
|
||||
shacl_validation: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
comments: Record<string, any>[];
|
||||
}
|
||||
|
||||
export function VersionsTab() {
|
||||
const [ontologyUri, setOntologyUri] = useState<string>("");
|
||||
const [versions, setVersions] = useState<VersionEntry[]>([]);
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [selectedProposal, setSelectedProposal] = useState<Proposal | null>(null);
|
||||
const [showProposalModal, setShowProposalModal] = useState(false);
|
||||
const [showCompareModal, setShowCompareModal] = useState(false);
|
||||
const [comparePair, setComparePair] = useState<{ v1: string; v2: string } | null>(null);
|
||||
const [compareResult, setCompareResult] = useState<Record<string, any> | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!ontologyUri) return;
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVersions(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load versions:", error);
|
||||
}
|
||||
}, [ontologyUri]);
|
||||
|
||||
const loadProposals = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/ontology/proposals");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProposals(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load proposals:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions();
|
||||
loadProposals();
|
||||
}, [loadVersions, loadProposals]);
|
||||
|
||||
const approveProposal = useCallback(async (proposalId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposalId}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal approved");
|
||||
loadProposals();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to approve proposal:", error);
|
||||
alert("Failed to approve proposal");
|
||||
}
|
||||
}, [loadProposals]);
|
||||
|
||||
const rejectProposal = useCallback(async (proposalId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposalId}/reject`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal rejected");
|
||||
loadProposals();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reject proposal:", error);
|
||||
alert("Failed to reject proposal");
|
||||
}
|
||||
}, [loadProposals]);
|
||||
|
||||
const publishProposal = useCallback(async (proposalId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/proposals/${proposalId}/publish`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (response.ok) {
|
||||
alert("Proposal published");
|
||||
loadProposals();
|
||||
loadVersions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to publish proposal:", error);
|
||||
alert("Failed to publish proposal");
|
||||
}
|
||||
}, [loadProposals, loadVersions]);
|
||||
|
||||
const runVersionComparison = useCallback(async () => {
|
||||
if (!comparePair || !ontologyUri) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/ontology/versions/${encodeURIComponent(ontologyUri)}/compare`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version1: comparePair.v1,
|
||||
version2: comparePair.v2,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCompareResult(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to compare versions:", error);
|
||||
alert("Failed to compare versions");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [comparePair, ontologyUri]);
|
||||
|
||||
const getStateIcon = (state: string) => {
|
||||
switch (state) {
|
||||
case "published":
|
||||
return <CheckCircle size={16} color="#4cc38a" />;
|
||||
case "approved":
|
||||
return <CheckCircle size={16} color="#4aa3ff" />;
|
||||
case "rejected":
|
||||
return <XCircle size={16} color="#ff6b6b" />;
|
||||
case "proposed":
|
||||
return <AlertCircle size={16} color="#f2b66d" />;
|
||||
default:
|
||||
return <Clock size={16} color="#8fa8c6" />;
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
background: "#07111f",
|
||||
padding: "20px",
|
||||
overflow: "auto",
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "20px",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#ebf3ff",
|
||||
fontSize: "20px",
|
||||
fontWeight: "700",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
marginBottom: "24px",
|
||||
};
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
margin: "0 0 12px",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "14px",
|
||||
fontWeight: "600",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
};
|
||||
|
||||
const listStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
};
|
||||
|
||||
const itemStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
background: "rgba(9, 19, 34, 0.8)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.12)",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const modalOverlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: "rgba(9, 19, 34, 0.95)",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
borderRadius: "12px",
|
||||
padding: "24px",
|
||||
minWidth: "480px",
|
||||
maxWidth: "640px",
|
||||
maxHeight: "80vh",
|
||||
overflow: "auto",
|
||||
backdropFilter: "blur(18px)",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "8px 14px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
background: "rgba(74, 163, 255, 0.1)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "12px",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
transition: "160ms ease",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(127, 208, 255, 0.2)",
|
||||
background: "rgba(3, 9, 18, 0.8)",
|
||||
color: "#ebf3ff",
|
||||
fontSize: "13px",
|
||||
marginBottom: "12px",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={headerStyle}>
|
||||
<h1 style={titleStyle}>Versions & Change Proposals</h1>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ontology URI"
|
||||
value={ontologyUri}
|
||||
onChange={(e) => setOntologyUri(e.target.value)}
|
||||
style={{ ...inputStyle, width: "300px", marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>
|
||||
<Layers size={16} />
|
||||
Version History
|
||||
</h2>
|
||||
<div style={listStyle}>
|
||||
{versions.length === 0 ? (
|
||||
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No versions found</div>
|
||||
) : (
|
||||
versions.map((version) => (
|
||||
<div key={version.version_id} style={itemStyle}>
|
||||
{getStateIcon(version.state)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
|
||||
{version.version_id}
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
|
||||
{version.author} • {new Date(version.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={() => {
|
||||
setComparePair({ v1: version.version_id, v2: versions[0]?.version_id || "" });
|
||||
setShowCompareModal(true);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
Compare
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>
|
||||
<GitMerge size={16} />
|
||||
Change Proposals
|
||||
</h2>
|
||||
<div style={listStyle}>
|
||||
{proposals.length === 0 ? (
|
||||
<div style={{ color: "#8fa8c6", fontSize: "13px" }}>No proposals found</div>
|
||||
) : (
|
||||
proposals.map((proposal) => (
|
||||
<div key={proposal.proposal_id} style={itemStyle}>
|
||||
{getStateIcon(proposal.state)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px", fontWeight: "600" }}>
|
||||
{proposal.summary}
|
||||
</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>
|
||||
{proposal.author} • {new Date(proposal.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{proposal.state === "proposed" && (
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
<button style={buttonStyle} onClick={() => approveProposal(proposal.proposal_id)}>
|
||||
<CheckCircle size={12} />
|
||||
Approve
|
||||
</button>
|
||||
<button style={buttonStyle} onClick={() => rejectProposal(proposal.proposal_id)}>
|
||||
<XCircle size={12} />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{proposal.state === "approved" && (
|
||||
<button style={buttonStyle} onClick={() => publishProposal(proposal.proposal_id)}>
|
||||
<Send size={12} />
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={buttonStyle}
|
||||
onClick={() => {
|
||||
setSelectedProposal(proposal);
|
||||
setShowProposalModal(true);
|
||||
}}
|
||||
>
|
||||
<FileText size={12} />
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProposalModal && selectedProposal && (
|
||||
<div style={modalOverlayStyle} onClick={() => setShowProposalModal(false)}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
|
||||
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: "16px" }}>Proposal Details</h3>
|
||||
<button onClick={() => setShowProposalModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Summary
|
||||
</label>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "13px" }}>{selectedProposal.summary}</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
State
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", color: "#ebf3ff", fontSize: "13px" }}>
|
||||
{getStateIcon(selectedProposal.state)}
|
||||
{selectedProposal.state}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Impact Analysis
|
||||
</label>
|
||||
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
|
||||
{JSON.stringify(selectedProposal.impact_analysis, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
SHACL Validation
|
||||
</label>
|
||||
<pre style={{ background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
|
||||
{JSON.stringify(selectedProposal.shacl_validation, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Comments ({selectedProposal.comments.length})
|
||||
</label>
|
||||
<div style={{ maxHeight: "120px", overflow: "auto" }}>
|
||||
{selectedProposal.comments.map((comment) => (
|
||||
<div key={comment.id} style={{ padding: "8px", background: "rgba(3, 9, 18, 0.6)", borderRadius: "4px", marginBottom: "6px" }}>
|
||||
<div style={{ color: "#ebf3ff", fontSize: "12px", fontWeight: "600" }}>{comment.author}</div>
|
||||
<div style={{ color: "#8fa8c6", fontSize: "11px" }}>{comment.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCompareModal && comparePair && (
|
||||
<div style={modalOverlayStyle} onClick={() => setShowCompareModal(false)}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
|
||||
<h3 style={{ margin: 0, color: "#ebf3ff", fontSize: "16px" }}>Compare Versions</h3>
|
||||
<button onClick={() => setShowCompareModal(false)} style={{ background: "none", border: "none", color: "#8fa8c6", cursor: "pointer" }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Version 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comparePair.v1}
|
||||
onChange={(e) => setComparePair({ ...comparePair, v1: e.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", color: "#8fa8c6", fontSize: "12px", marginBottom: "4px" }}>
|
||||
Version 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comparePair.v2}
|
||||
onChange={(e) => setComparePair({ ...comparePair, v2: e.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button style={buttonStyle} onClick={runVersionComparison} disabled={isLoading}>
|
||||
<Scale size={12} />
|
||||
{isLoading ? "Comparing..." : "Compare"}
|
||||
</button>
|
||||
{compareResult && (
|
||||
<pre style={{ marginTop: "16px", background: "rgba(3, 9, 18, 0.8)", padding: "12px", borderRadius: "6px", color: "#ebf3ff", fontSize: "12px", overflow: "auto" }}>
|
||||
{JSON.stringify(compareResult, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
AlignmentRelation,
|
||||
AlignmentSuggestion,
|
||||
OntologyAlignment,
|
||||
OntologyEntry,
|
||||
OntologyHealthResponse,
|
||||
ShaclGenerateResponse,
|
||||
ShaclShapesResponse,
|
||||
ShaclValidationResponse,
|
||||
} from "./types";
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let detail = `Request failed with status ${response.status}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body.detail || detail;
|
||||
} catch {
|
||||
// Keep the generic HTTP detail.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function loadOntologyRegistry(): Promise<OntologyEntry[]> {
|
||||
return parseResponse<OntologyEntry[]>(await fetch("/api/ontology/registry"));
|
||||
}
|
||||
|
||||
export async function loadAlignments(uri?: string): Promise<OntologyAlignment[]> {
|
||||
const query = uri ? `?uri=${encodeURIComponent(uri)}` : "";
|
||||
return parseResponse<OntologyAlignment[]>(await fetch(`/api/ontology/alignments${query}`));
|
||||
}
|
||||
|
||||
export async function saveAlignment(payload: {
|
||||
source_uri: string;
|
||||
target_uri: string;
|
||||
relation: AlignmentRelation;
|
||||
confidence: number;
|
||||
provenance?: string;
|
||||
source?: string;
|
||||
reviewer?: string;
|
||||
}): Promise<OntologyAlignment> {
|
||||
return parseResponse<OntologyAlignment>(
|
||||
await fetch("/api/ontology/alignments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeAlignment(id: string): Promise<void> {
|
||||
await parseResponse<{ status: string }>(
|
||||
await fetch(`/api/ontology/alignments?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function suggestAlignments(payload: {
|
||||
source_ontology_uri?: string;
|
||||
target_ontology_uri?: string;
|
||||
threshold: number;
|
||||
limit: number;
|
||||
}): Promise<AlignmentSuggestion[]> {
|
||||
return parseResponse<AlignmentSuggestion[]>(
|
||||
await fetch("/api/ontology/suggest-alignments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadOntologyHealth(uri: string): Promise<OntologyHealthResponse> {
|
||||
return parseResponse<OntologyHealthResponse>(
|
||||
await fetch(`/api/ontology/health?uri=${encodeURIComponent(uri)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateShacl(uri: string, qualityTier: "standard" | "strict" = "strict"): Promise<ShaclGenerateResponse> {
|
||||
return parseResponse<ShaclGenerateResponse>(
|
||||
await fetch("/api/ontology/shacl/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uri, quality_tier: qualityTier }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadShaclShapes(uri: string): Promise<ShaclShapesResponse> {
|
||||
return parseResponse<ShaclShapesResponse>(
|
||||
await fetch(`/api/ontology/shacl/shapes?uri=${encodeURIComponent(uri)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateShacl(uri: string, shaclTurtle: string): Promise<ShaclValidationResponse> {
|
||||
return parseResponse<ShaclValidationResponse>(
|
||||
await fetch("/api/ontology/shacl/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uri, shacl_turtle: shaclTurtle }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
GitMerge,
|
||||
HeartPulse,
|
||||
Layers,
|
||||
Shield,
|
||||
Sliders,
|
||||
} from "lucide-react";
|
||||
import { AlignmentsTab } from "./AlignmentsTab";
|
||||
import { HealthTab } from "./HealthTab";
|
||||
import { OntologyManager } from "./OntologyManager";
|
||||
import { OntologyEditor } from "./OntologyEditor";
|
||||
import { ShaclStudio } from "./ShaclStudio";
|
||||
import { VersionsTab } from "./VersionsTab";
|
||||
|
||||
export type OntologyHubTab =
|
||||
| "registry"
|
||||
| "editor"
|
||||
| "versions"
|
||||
| "alignments"
|
||||
| "health"
|
||||
| "shacl";
|
||||
|
||||
const TAB_PARAM = "ontologyTab";
|
||||
|
||||
const TABS: { id: OntologyHubTab; label: string; icon: typeof GitMerge }[] = [
|
||||
{ id: "registry", label: "Registry", icon: BookMarked },
|
||||
{ id: "editor", label: "Editor", icon: Sliders },
|
||||
{ id: "versions", label: "Versions", icon: Layers },
|
||||
{ id: "alignments", label: "Alignments", icon: GitMerge },
|
||||
{ id: "health", label: "Health", icon: HeartPulse },
|
||||
{ id: "shacl", label: "SHACL", icon: Shield },
|
||||
];
|
||||
|
||||
function readTabParam(): OntologyHubTab {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(TAB_PARAM);
|
||||
if (raw && TABS.some((t) => t.id === raw)) return raw as OntologyHubTab;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "registry";
|
||||
}
|
||||
|
||||
function writeTabParam(tab: OntologyHubTab) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, tab);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function ComingSoonStub({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
}: {
|
||||
icon: typeof GitMerge;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={stubShellStyle}>
|
||||
<div style={stubCardStyle}>
|
||||
<div style={stubIconRingStyle}>
|
||||
<Icon size={28} color="#7fd0ff" />
|
||||
</div>
|
||||
<div style={stubBadgeStyle}>{badge}</div>
|
||||
<h2 style={stubTitleStyle}>{title}</h2>
|
||||
<p style={stubDescStyle}>{description}</p>
|
||||
<div style={stubDividerStyle} />
|
||||
<p style={stubSubnoteStyle}>Coming in Subissue 2 / 3 of Ontology Hub</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OntologyWorkspaceProps {
|
||||
onJumpToGraphNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function OntologyWorkspace({ onJumpToGraphNode }: OntologyWorkspaceProps) {
|
||||
const [activeTab, setActiveTab] = useState<OntologyHubTab>(readTabParam);
|
||||
|
||||
useEffect(() => {
|
||||
writeTabParam(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleTabChange = useCallback((tab: OntologyHubTab) => {
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
const handleFixInEditor = useCallback((entityUri: string) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(TAB_PARAM, "editor");
|
||||
params.set("ontologyEntity", entityUri);
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
setActiveTab("editor");
|
||||
}, []);
|
||||
|
||||
const renderTab = () => {
|
||||
switch (activeTab) {
|
||||
case "registry":
|
||||
return <OntologyManager />;
|
||||
case "editor":
|
||||
return <OntologyEditor />;
|
||||
case "versions":
|
||||
return <VersionsTab />;
|
||||
case "alignments":
|
||||
return <AlignmentsTab />;
|
||||
case "health":
|
||||
return <HealthTab onFixInEditor={handleFixInEditor} />;
|
||||
case "shacl":
|
||||
return <ShaclStudio onJumpToNode={onJumpToGraphNode} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div style={tabBarStyle}>
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
style={{
|
||||
...tabBtnBase,
|
||||
...(activeTab === id ? tabBtnActive : tabBtnIdle),
|
||||
}}
|
||||
onClick={() => handleTabChange(id)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={contentStyle}>{renderTab()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "#07111f",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const tabBarStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
padding: "10px 18px",
|
||||
borderBottom: "1px solid rgba(140,192,255,0.12)",
|
||||
background: "rgba(3,9,18,0.72)",
|
||||
flexShrink: 0,
|
||||
flexWrap: "wrap",
|
||||
};
|
||||
|
||||
const tabBtnBase: React.CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 13px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
transition: "160ms ease",
|
||||
background: "transparent",
|
||||
};
|
||||
|
||||
const tabBtnIdle: React.CSSProperties = {
|
||||
color: "#8fa8c6",
|
||||
borderColor: "rgba(127,208,255,0.1)",
|
||||
};
|
||||
|
||||
const tabBtnActive: React.CSSProperties = {
|
||||
color: "#ebf3ff",
|
||||
background: "rgba(74,163,255,0.16)",
|
||||
borderColor: "rgba(127,208,255,0.3)",
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)",
|
||||
};
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const stubShellStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(180deg, rgba(7,17,31,0.8), rgba(5,11,21,0.95))",
|
||||
};
|
||||
|
||||
const stubCardStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "48px 52px",
|
||||
borderRadius: 28,
|
||||
border: "1px solid rgba(127,208,255,0.12)",
|
||||
background: "rgba(9,19,34,0.82)",
|
||||
boxShadow: "0 24px 64px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.06)",
|
||||
maxWidth: 480,
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
const stubIconRingStyle: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "rgba(74,163,255,0.1)",
|
||||
border: "1px solid rgba(127,208,255,0.18)",
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const stubBadgeStyle: React.CSSProperties = {
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(242,182,109,0.1)",
|
||||
border: "1px solid rgba(242,182,109,0.22)",
|
||||
color: "#f2b66d",
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const stubTitleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#ebf3ff",
|
||||
fontSize: 22,
|
||||
fontWeight: 800,
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const stubDescStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#8fa8c6",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.65,
|
||||
maxWidth: 360,
|
||||
};
|
||||
|
||||
const stubDividerStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
background: "rgba(127,208,255,0.08)",
|
||||
};
|
||||
|
||||
const stubSubnoteStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
color: "#5a7a9a",
|
||||
fontSize: 12,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
export interface OntologyEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
format: string;
|
||||
status: "published" | "draft" | "external";
|
||||
source_url?: string;
|
||||
version?: string;
|
||||
class_count: number;
|
||||
concept_count: number;
|
||||
property_count: number;
|
||||
loaded_at: string;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type AlignmentRelation =
|
||||
| "owl:equivalentClass"
|
||||
| "owl:equivalentProperty"
|
||||
| "skos:exactMatch"
|
||||
| "skos:closeMatch"
|
||||
| "skos:broadMatch"
|
||||
| "skos:narrowMatch"
|
||||
| "skos:relatedMatch";
|
||||
|
||||
export interface OntologyAlignment {
|
||||
id: string;
|
||||
source_uri: string;
|
||||
source_label: string;
|
||||
target_uri: string;
|
||||
target_label: string;
|
||||
relation: AlignmentRelation;
|
||||
predicate_uri: string;
|
||||
confidence: number;
|
||||
provenance?: string;
|
||||
source?: string;
|
||||
reviewer?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlignmentSuggestion {
|
||||
source_uri: string;
|
||||
source_label: string;
|
||||
target_uri: string;
|
||||
target_label: string;
|
||||
relation: AlignmentRelation;
|
||||
score: number;
|
||||
label_similarity: number;
|
||||
embedding_similarity?: number | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface HealthDimension {
|
||||
key: string;
|
||||
label: string;
|
||||
score: number;
|
||||
status: "ok" | "warning" | "critical" | "unavailable";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface HealthIssue {
|
||||
id: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
category: string;
|
||||
entity_uri?: string;
|
||||
entity_label?: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface OntologyHealthResponse {
|
||||
uri: string;
|
||||
name: string;
|
||||
total_score: number;
|
||||
dimensions: HealthDimension[];
|
||||
issues: HealthIssue[];
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface ShaclShapeSummary {
|
||||
id: string;
|
||||
target_class?: string;
|
||||
constraint_count: number;
|
||||
constraints: string[];
|
||||
violation_count: number;
|
||||
}
|
||||
|
||||
export interface ShaclViolation {
|
||||
node?: string;
|
||||
path?: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
focus_node?: string;
|
||||
source_shape?: string;
|
||||
}
|
||||
|
||||
export interface ShaclGenerateResponse {
|
||||
uri: string;
|
||||
shacl_turtle: string;
|
||||
shape_count: number;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface ShaclShapesResponse {
|
||||
uri: string;
|
||||
shapes: ShaclShapeSummary[];
|
||||
shacl_turtle: string;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface ShaclValidationResponse {
|
||||
uri?: string;
|
||||
conforms: boolean;
|
||||
status: "success" | "unavailable" | "error";
|
||||
message: string;
|
||||
violations: ShaclViolation[];
|
||||
report_text?: string;
|
||||
}
|
||||
@@ -5,13 +5,33 @@ import {
|
||||
batchMergeEdges,
|
||||
batchMergeNodes,
|
||||
clearGraph,
|
||||
graph,
|
||||
} from "../src/store/graphStore.ts";
|
||||
import {
|
||||
buildGraphAnalyticsSnapshot,
|
||||
computeGraphAnalyticsBase,
|
||||
} from "../src/workspaces/GraphWorkspace/graphAnalytics.ts";
|
||||
import {
|
||||
buildHeatmapRenderSnapshot,
|
||||
buildStructuralDistanceSnapshot,
|
||||
classifyFullGraphEdge,
|
||||
checkGroupedViewAvailability,
|
||||
mapFullEdgeClassToVisualState,
|
||||
resolveDistanceEdgeStyle,
|
||||
resolveDistanceNodeStyle,
|
||||
resolveEdgeElementStyle,
|
||||
resolveEdgeVisualState,
|
||||
resolveDisplayGraph,
|
||||
resolveGroupedDisplayNodeId,
|
||||
resolveGroupedDisplayStateSnapshot,
|
||||
summarizeDistanceBuckets,
|
||||
} from "../src/workspaces/GraphWorkspace/graphSceneState.ts";
|
||||
import {
|
||||
buildGraphStructureCurveCache,
|
||||
evaluateGraphStructureLayerGate,
|
||||
} from "../src/workspaces/GraphWorkspace/graphStructureLayer.ts";
|
||||
import { GRAPH_THEME } from "../src/workspaces/GraphWorkspace/graphTheme.ts";
|
||||
import type { GraphDistanceVisualState, GraphFullEdgeClass, GraphFullEdgeClassCounts } from "../src/workspaces/GraphWorkspace/types.ts";
|
||||
|
||||
function addNode(id: string, semanticGroup = "entity") {
|
||||
batchMergeNodes([
|
||||
@@ -33,6 +53,10 @@ function addNode(id: string, semanticGroup = "entity") {
|
||||
]);
|
||||
}
|
||||
|
||||
function setNodePosition(id: string, x: number, y: number) {
|
||||
graph.mergeNodeAttributes(id, { x, y });
|
||||
}
|
||||
|
||||
function addEdge(id: string, source: string, target: string, weight = 1) {
|
||||
batchMergeEdges([
|
||||
{
|
||||
@@ -56,6 +80,785 @@ test.after(() => {
|
||||
clearGraph();
|
||||
});
|
||||
|
||||
const BASE_NODE_STYLE = {
|
||||
color: "#63E6FF",
|
||||
shellColor: "#63E6FF",
|
||||
coreScale: 1,
|
||||
size: 8,
|
||||
forceLabel: false,
|
||||
label: "node",
|
||||
zIndex: 1,
|
||||
hidden: false,
|
||||
borderColor: "#63E6FF",
|
||||
borderSize: 1,
|
||||
nodeVariant: "default",
|
||||
entityShape: "entity",
|
||||
entityShapeKind: 0,
|
||||
entityAspectRatio: 1,
|
||||
showBadge: false,
|
||||
showRing: false,
|
||||
ringSize: 0,
|
||||
showHalo: false,
|
||||
haloColor: "transparent",
|
||||
} as const;
|
||||
|
||||
const BASE_EDGE_STYLE = {
|
||||
hidden: true,
|
||||
color: "#334155",
|
||||
size: 0.5,
|
||||
zIndex: 0,
|
||||
edgeVariant: "line",
|
||||
arrowVisibilityPolicy: "hidden",
|
||||
curveStrength: 0,
|
||||
curvature: 0,
|
||||
} as const;
|
||||
|
||||
function makeDistanceState(overrides: Partial<GraphDistanceVisualState>): GraphDistanceVisualState {
|
||||
return {
|
||||
mode: "off",
|
||||
anchorNodeId: null,
|
||||
anchorLabel: null,
|
||||
maxHops: 2,
|
||||
structuralDistances: {},
|
||||
semanticScores: {},
|
||||
semanticNeighborCount: 0,
|
||||
status: "ready",
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildStructuralDistanceSnapshot returns bounded BFS hop distances", () => {
|
||||
addNode("anchor");
|
||||
addNode("near");
|
||||
addNode("far");
|
||||
addNode("outside");
|
||||
addNode("too-far");
|
||||
addEdge("e-anchor-near", "anchor", "near");
|
||||
addEdge("e-near-far", "near", "far");
|
||||
addEdge("e-far-outside", "far", "outside");
|
||||
addEdge("e-outside-too-far", "outside", "too-far");
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
|
||||
assert.equal(distances.anchor, 0);
|
||||
assert.equal(distances.near, 1);
|
||||
assert.equal(distances.far, 2);
|
||||
assert.equal(distances.outside, 3);
|
||||
assert.equal(distances["too-far"], undefined);
|
||||
});
|
||||
|
||||
test("summarizeDistanceBuckets reports local rings and outside count", () => {
|
||||
const counts = summarizeDistanceBuckets({
|
||||
anchor: 0,
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
}, 6);
|
||||
|
||||
assert.deepEqual(counts, {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildHeatmapRenderSnapshot caps and deterministically samples large rings", () => {
|
||||
addNode("anchor");
|
||||
for (let index = 0; index < 130; index += 1) {
|
||||
const nodeId = `one-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 7, labelPriority: index % 5 });
|
||||
addEdge(`edge-anchor-${nodeId}`, "anchor", nodeId, index % 11);
|
||||
}
|
||||
for (let index = 0; index < 700; index += 1) {
|
||||
const nodeId = `two-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 13, labelPriority: index % 3 });
|
||||
addEdge(`edge-one-two-${index}`, `one-${index % 130}`, nodeId, index % 17);
|
||||
}
|
||||
for (let index = 0; index < 950; index += 1) {
|
||||
const nodeId = `three-${index}`;
|
||||
addNode(nodeId);
|
||||
graph.mergeNodeAttributes(nodeId, { visualPriority: index % 19, labelPriority: index % 4 });
|
||||
addEdge(`edge-two-three-${index}`, `two-${index % 700}`, nodeId, index % 23);
|
||||
}
|
||||
|
||||
const distances = buildStructuralDistanceSnapshot(graph, "anchor", 3);
|
||||
const firstSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
const secondSnapshot = buildHeatmapRenderSnapshot(graph, "anchor", distances, 3);
|
||||
|
||||
assert.equal(firstSnapshot.ringCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.ringCounts.oneHop, 130);
|
||||
assert.equal(firstSnapshot.ringCounts.twoHop, 700);
|
||||
assert.equal(firstSnapshot.ringCounts.threeHop, 950);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.anchor, 1);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.oneHop, 120);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.twoHop, 650);
|
||||
assert.equal(firstSnapshot.renderedRingCounts.threeHop, 900);
|
||||
assert.equal(firstSnapshot.saturationMode, "sampled");
|
||||
assert.deepEqual(firstSnapshot.visibleNodeIds, secondSnapshot.visibleNodeIds);
|
||||
assert.ok(firstSnapshot.visibleNodeIds.includes("anchor"));
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies ego muting without mutating graph data", () => {
|
||||
const state = makeDistanceState({
|
||||
mode: "ego",
|
||||
anchorNodeId: "anchor",
|
||||
anchorLabel: "Anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "anchor");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "inspection", BASE_NODE_STYLE, state, "outside");
|
||||
|
||||
assert.equal(anchorStyle.forceLabel, true);
|
||||
assert.equal(anchorStyle.label, "Anchor");
|
||||
assert.ok(Number(anchorStyle.size) > BASE_NODE_STYLE.size);
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle applies readable heatmap rings only when ready", () => {
|
||||
const readyState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 1,
|
||||
twoHop: 1,
|
||||
threeHop: 1,
|
||||
outside: 1,
|
||||
},
|
||||
});
|
||||
const loadingState = makeDistanceState({ ...readyState, status: "loading" });
|
||||
|
||||
const anchorStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "anchor");
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, readyState, "outside");
|
||||
const loadingStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, loadingState, "near");
|
||||
|
||||
assert.notEqual(anchorStyle.color, oneHopStyle.color);
|
||||
assert.notEqual(oneHopStyle.color, twoHopStyle.color);
|
||||
assert.notEqual(twoHopStyle.color, threeHopStyle.color);
|
||||
assert.ok(Number(anchorStyle.size) > Number(oneHopStyle.size));
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.equal(outsideStyle.label, "");
|
||||
assert.ok(Number(outsideStyle.size) < BASE_NODE_STYLE.size);
|
||||
assert.deepEqual(loadingStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle compresses saturated heatmap far rings", () => {
|
||||
const saturatedState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1, two: 2, three: 3 },
|
||||
heatmapVisibleNodeIds: ["anchor", "one", "two", "three"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 32,
|
||||
twoHop: 3350,
|
||||
threeHop: 7412,
|
||||
outside: 3280,
|
||||
},
|
||||
});
|
||||
|
||||
const oneHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "one");
|
||||
const twoHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "two");
|
||||
const threeHopStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "three");
|
||||
const outsideStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, saturatedState, "outside");
|
||||
|
||||
assert.ok(Number(oneHopStyle.size) > Number(twoHopStyle.size));
|
||||
assert.ok(Number(twoHopStyle.size) > Number(threeHopStyle.size));
|
||||
assert.ok(Number(threeHopStyle.size) > Number(outsideStyle.size));
|
||||
assert.equal(threeHopStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceNodeStyle mutes unsampled heatmap nodes instead of coloring them", () => {
|
||||
const sampledState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, rendered: 2, unsampled: 2 },
|
||||
heatmapVisibleNodeIds: ["anchor", "rendered"],
|
||||
distanceCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 2,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapRenderedRingCounts: {
|
||||
anchor: 1,
|
||||
oneHop: 0,
|
||||
twoHop: 1,
|
||||
threeHop: 0,
|
||||
outside: 0,
|
||||
},
|
||||
heatmapSaturationMode: "sampled",
|
||||
});
|
||||
|
||||
const renderedStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "rendered");
|
||||
const unsampledStyle = resolveDistanceNodeStyle(GRAPH_THEME, "overview", BASE_NODE_STYLE, sampledState, "unsampled");
|
||||
|
||||
assert.notEqual(renderedStyle.color, unsampledStyle.color);
|
||||
assert.ok(Number(renderedStyle.size) > Number(unsampledStyle.size));
|
||||
assert.equal(unsampledStyle.label, "");
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle reveals structural and semantic context edges", () => {
|
||||
const structuralState = makeDistanceState({
|
||||
mode: "structural",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 2,
|
||||
structuralDistances: { anchor: 0, near: 1 },
|
||||
});
|
||||
const semanticState = makeDistanceState({
|
||||
mode: "semantic",
|
||||
anchorNodeId: "anchor",
|
||||
semanticScores: { semantic: 0.82 },
|
||||
});
|
||||
|
||||
const structuralStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, structuralState, "anchor", "near");
|
||||
const semanticStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "anchor", "semantic");
|
||||
const unrelatedStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, semanticState, "near", "semantic");
|
||||
|
||||
assert.equal(structuralStyle.hidden, false);
|
||||
assert.equal(semanticStyle.hidden, false);
|
||||
assert.deepEqual(unrelatedStyle, {});
|
||||
});
|
||||
|
||||
test("resolveDistanceEdgeStyle suppresses heatmap background edges but preserves context", () => {
|
||||
const heatmapState = makeDistanceState({
|
||||
mode: "heatmap",
|
||||
anchorNodeId: "anchor",
|
||||
maxHops: 3,
|
||||
structuralDistances: { anchor: 0, one: 1 },
|
||||
});
|
||||
|
||||
const backgroundStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "backbone");
|
||||
const contextStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "local-context");
|
||||
const pathStyle = resolveDistanceEdgeStyle(BASE_EDGE_STYLE, heatmapState, "anchor", "one", "path");
|
||||
|
||||
assert.equal(backgroundStyle.hidden, true);
|
||||
assert.deepEqual(contextStyle, {});
|
||||
assert.deepEqual(pathStyle, {});
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState caps selected-node incident edge promotion", () => {
|
||||
const uncappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
assert.equal(uncappedState, "muted");
|
||||
|
||||
const cappedState = resolveEdgeVisualState(
|
||||
"edge-1",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["edge-1"]),
|
||||
);
|
||||
assert.equal(cappedState, "selected");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle applies full-graph LOD to directional background edges", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"default",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
edgeVariant: "directional",
|
||||
visualPriority: 0.1,
|
||||
baseSize: 0.5,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"directional-low-priority",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, true);
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge applies deterministic priority order", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
new Set(["edge-priority"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "path");
|
||||
|
||||
const selectedClass = classifyFullGraphEdge(
|
||||
"edge-priority",
|
||||
"source",
|
||||
"target",
|
||||
"inspection",
|
||||
"source",
|
||||
"source",
|
||||
"edge-priority",
|
||||
new Set(["source", "target"]),
|
||||
new Set(),
|
||||
new Set(["edge-priority"]),
|
||||
);
|
||||
|
||||
assert.equal(selectedClass, "selected");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge separates capped local context from muted hub edges", () => {
|
||||
const mutedClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(mutedClass, "muted");
|
||||
|
||||
const localContextClass = classifyFullGraphEdge(
|
||||
"hub-edge",
|
||||
"hub",
|
||||
"leaf",
|
||||
"inspection",
|
||||
null,
|
||||
"hub",
|
||||
"",
|
||||
new Set(["hub", "leaf"]),
|
||||
new Set(),
|
||||
new Set(["hub-edge"]),
|
||||
);
|
||||
|
||||
assert.equal(localContextClass, "local-context");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge marks curated bridge and backbone candidates", () => {
|
||||
const bridgeClass = classifyFullGraphEdge(
|
||||
"curated-bridge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-bridge"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "disease", content: "target", semanticGroup: "disease", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(bridgeClass, "bridge");
|
||||
|
||||
const backboneClass = classifyFullGraphEdge(
|
||||
"curated-backbone",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(["curated-backbone"]),
|
||||
{ label: "source", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "source", semanticGroup: "gene", properties: {} },
|
||||
{ label: "target", x: 0, y: 0, size: 1, color: "#fff", nodeType: "gene", content: "target", semanticGroup: "gene", properties: {} },
|
||||
);
|
||||
|
||||
assert.equal(backboneClass, "backbone");
|
||||
});
|
||||
|
||||
test("classifyFullGraphEdge hides ordinary full-graph overview edges", () => {
|
||||
const edgeClass = classifyFullGraphEdge(
|
||||
"ordinary-edge",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(edgeClass, "hidden");
|
||||
});
|
||||
|
||||
test("mapFullEdgeClassToVisualState renders curated backbone and bridge as backbone", () => {
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("backbone", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("bridge", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"backbone",
|
||||
);
|
||||
assert.equal(
|
||||
mapFullEdgeClassToVisualState("hidden", { hoveredNodeId: null, hasActiveInteraction: false }),
|
||||
"inactive",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders curated full-graph backbone quietly", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-backbone-edge",
|
||||
"backbone",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.08\)/);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.backboneMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle renders high-value bridge as a calm curved teal edge", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 4,
|
||||
properties: {},
|
||||
visualPriority: 0.95,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"curated-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "curve");
|
||||
assert.notEqual(style.type, "arrow");
|
||||
assert.match(style.color ?? "", /rgba\(.+,\s*0\.14\)/);
|
||||
assert.equal(style.curvature, GRAPH_THEME.edges.fullGraphStructure.bridgeCurveStrength);
|
||||
assert.ok(Number(style.size ?? 0) <= GRAPH_THEME.edges.fullGraphStructure.bridgeMaxSize);
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps low-priority bridge straight", () => {
|
||||
const style = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"overview",
|
||||
"backbone",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 1,
|
||||
properties: {},
|
||||
visualPriority: 0.2,
|
||||
baseSize: 0.9,
|
||||
edgeVariant: "line",
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"low-value-bridge-edge",
|
||||
"bridge",
|
||||
);
|
||||
|
||||
assert.equal(style.hidden, false);
|
||||
assert.equal(style.type, "line");
|
||||
assert.equal(style.curvature, 0);
|
||||
});
|
||||
|
||||
test("evaluateGraphStructureLayerGate enables only sparse settled full-graph structure", () => {
|
||||
const counts: GraphFullEdgeClassCounts = {
|
||||
hidden: 20,
|
||||
backbone: 6,
|
||||
bridge: 4,
|
||||
"local-context": 0,
|
||||
selected: 0,
|
||||
path: 0,
|
||||
muted: 0,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "grouped",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "grouped",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "non-full-mode" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: true,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "layout-running" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 24,
|
||||
counts: { ...counts, backbone: 18, bridge: 6 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: false, disabledReason: "enough-literal-edges" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateGraphStructureLayerGate({
|
||||
mode: "auto",
|
||||
viewMode: "full",
|
||||
isLayoutRunning: false,
|
||||
edgeDiagnostics: {
|
||||
mode: "full",
|
||||
zoomTier: "overview",
|
||||
totalEdges: 30,
|
||||
visibleEdges: 10,
|
||||
counts,
|
||||
updatedAt: 1,
|
||||
},
|
||||
minimumLiteralEdges: 24,
|
||||
}),
|
||||
{ enabled: true, disabledReason: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGraphStructureCurveCache prefers bridges, caps curves, and skips invalid endpoints", () => {
|
||||
addNode("a", "gene");
|
||||
addNode("b", "disease");
|
||||
addNode("c", "gene");
|
||||
addNode("d", "compound");
|
||||
setNodePosition("a", 0, 0);
|
||||
setNodePosition("b", 100, 0);
|
||||
setNodePosition("c", 0, 100);
|
||||
setNodePosition("d", Number.NaN, 100);
|
||||
|
||||
addEdge("backbone-1", "a", "c", 1);
|
||||
graph.mergeEdgeAttributes("backbone-1", { visualPriority: 1 });
|
||||
addEdge("bridge-1", "a", "b", 0.2);
|
||||
graph.mergeEdgeAttributes("bridge-1", { visualPriority: 0.1 });
|
||||
addEdge("selected-1", "b", "c", 1);
|
||||
graph.mergeEdgeAttributes("selected-1", { visualPriority: 1 });
|
||||
addEdge("invalid-bridge", "a", "d", 1);
|
||||
graph.mergeEdgeAttributes("invalid-bridge", { visualPriority: 1 });
|
||||
|
||||
const edgeClasses = new Map<string, GraphFullEdgeClass>([
|
||||
["backbone-1", "backbone"],
|
||||
["bridge-1", "bridge"],
|
||||
["selected-1", "selected"],
|
||||
["invalid-bridge", "bridge"],
|
||||
]);
|
||||
|
||||
const capped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 1,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.equal(capped.curves.length, 1);
|
||||
assert.equal(capped.curves[0].edgeId, "bridge-1");
|
||||
assert.equal(capped.bridgeCurveCount, 1);
|
||||
assert.equal(capped.backboneCurveCount, 0);
|
||||
|
||||
const uncapped = buildGraphStructureCurveCache({
|
||||
graphRef: graph,
|
||||
cacheKey: "test-cache-all",
|
||||
classifyEdge: (edgeId) => edgeClasses.get(edgeId) ?? "hidden",
|
||||
maxCurves: 10,
|
||||
curveStrength: 0.12,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
uncapped.curves.map((curve) => curve.edgeId).sort(),
|
||||
["backbone-1", "bridge-1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveEdgeVisualState suppresses automatic overview backbone in clean baseline", () => {
|
||||
const state = resolveEdgeVisualState(
|
||||
"overview-backbone-high-priority",
|
||||
"source",
|
||||
"target",
|
||||
"overview",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
new Set(),
|
||||
new Set(),
|
||||
);
|
||||
|
||||
assert.equal(state, "inactive");
|
||||
});
|
||||
|
||||
test("resolveEdgeElementStyle keeps full-graph selected and path edges controlled", () => {
|
||||
const selectedStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"selected",
|
||||
{
|
||||
edgeType: "related_to",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"selected-context-edge",
|
||||
);
|
||||
const pathStyle = resolveEdgeElementStyle(
|
||||
GRAPH_THEME,
|
||||
"inspection",
|
||||
"path",
|
||||
{
|
||||
edgeType: "causes",
|
||||
weight: 2,
|
||||
properties: {},
|
||||
visualPriority: 0.9,
|
||||
baseSize: 0.8,
|
||||
},
|
||||
"source",
|
||||
"target",
|
||||
"full",
|
||||
"path-context-edge",
|
||||
);
|
||||
|
||||
assert.equal(selectedStyle.hidden, false);
|
||||
assert.match(selectedStyle.color ?? "", /rgba\(.+,\s*0\.6\)/);
|
||||
assert.equal(pathStyle.hidden, false);
|
||||
assert.match(pathStyle.color ?? "", /rgba\(.+,\s*0\.76\)/);
|
||||
});
|
||||
|
||||
test("buildGraphAnalyticsSnapshot emits a readable capped overview backbone", () => {
|
||||
const semanticGroups = ["gene/protein", "disease", "drug", "pathway"];
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
addNode(`n${index}`, semanticGroups[index % semanticGroups.length]);
|
||||
}
|
||||
|
||||
let edgeIndex = 0;
|
||||
for (let sourceIndex = 0; sourceIndex < 16; sourceIndex += 1) {
|
||||
for (let offset = 1; offset <= 3; offset += 1) {
|
||||
const targetIndex = (sourceIndex + offset * 3) % 16;
|
||||
if (sourceIndex === targetIndex) {
|
||||
continue;
|
||||
}
|
||||
addEdge(`ambient-edge-${edgeIndex}`, `n${sourceIndex}`, `n${targetIndex}`, 1 + (edgeIndex % 5));
|
||||
edgeIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const base = computeGraphAnalyticsBase(graph, {
|
||||
computeCommunities: false,
|
||||
computeCentrality: true,
|
||||
});
|
||||
const analytics = buildGraphAnalyticsSnapshot({
|
||||
graphRef: graph,
|
||||
interactionState: {
|
||||
hoveredNodeId: null,
|
||||
selectedNodeId: "",
|
||||
selectedEdgeId: "",
|
||||
focusedNodeId: "",
|
||||
activePath: [],
|
||||
activePathEdgeIds: [],
|
||||
viewMode: "full",
|
||||
zoomTier: "overview",
|
||||
isLayoutRunning: false,
|
||||
},
|
||||
base,
|
||||
visibleNodeIds: graph.nodes(),
|
||||
});
|
||||
|
||||
assert.equal(analytics.overviewBackbone.ready, true);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length > 6);
|
||||
assert.ok(analytics.overviewBackbone.edgeIds.length <= 128);
|
||||
});
|
||||
|
||||
test("resolveDisplayGraph bundles parallel edges in full view", () => {
|
||||
addNode("a");
|
||||
addNode("b");
|
||||
@@ -257,3 +1060,4 @@ test("checkGroupedViewAvailability returns available when communities exist", ()
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.reason, null);
|
||||
});
|
||||
|
||||
|
||||
+4
-3
@@ -211,10 +211,10 @@ explorer-lite = [
|
||||
"streamlit-agraph>=0.0.45"
|
||||
]
|
||||
|
||||
# Everything
|
||||
# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux)
|
||||
all = [
|
||||
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,explorer]",
|
||||
"semantica[dev,viz,gpu,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,agno]"
|
||||
"semantica[dev,viz,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,explorer]",
|
||||
"semantica[dev,viz,infra,cloud,monitoring,llm-all,models-huggingface,split-all,graph-all,vectorstore-all,parse-docling,agno]"
|
||||
]
|
||||
|
||||
# ---------------- ENTRYPOINTS ----------------
|
||||
@@ -223,6 +223,7 @@ semantica = "semantica.cli:main"
|
||||
semantica-server = "semantica.server:main"
|
||||
semantica-worker = "semantica.worker:main"
|
||||
semantica-explorer = "semantica.explorer:main"
|
||||
semantica-mcp = "semantica.mcp_server:main"
|
||||
|
||||
# ---------------- TOOLING ----------------
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
mkdocs>=1.5.0
|
||||
mkdocs-material>=9.4.0
|
||||
mkdocs>=1.6.1
|
||||
mkdocs-material>=9.7.6
|
||||
mkdocs-minify-plugin>=0.7.0
|
||||
mkdocs-mermaid2-plugin>=1.0.0
|
||||
pymdown-extensions>=10.0
|
||||
mkdocs-mermaid2-plugin>=1.2.3
|
||||
pymdown-extensions>=10.21.2
|
||||
|
||||
mkdocstrings[python]>=0.24.0
|
||||
mkdocs-jupyter>=0.24.0
|
||||
mkdocs-jupyter>=0.26.3
|
||||
|
||||
|
||||
@@ -133,48 +133,6 @@ class ConflictDetector:
|
||||
|
||||
self.detected_conflicts: Dict[str, Conflict] = {}
|
||||
|
||||
def detect_conflicts(
|
||||
self,
|
||||
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
|
||||
method: str = "entity",
|
||||
property_name: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> List[Conflict]:
|
||||
"""
|
||||
Detect conflicts using the specified method (convenience method).
|
||||
|
||||
Args:
|
||||
entities: Entities to check (List of dicts or a KG dict)
|
||||
method: Detection method ("entity", "value", "type", "relationship", "temporal", "logical")
|
||||
property_name: Property name for "value" method
|
||||
entity_type: Optional entity type filter
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
List of detected conflicts
|
||||
"""
|
||||
# If passed a KG dict, extract entities
|
||||
if isinstance(entities, dict) and "entities" in entities:
|
||||
entities = entities["entities"]
|
||||
|
||||
if method == "value":
|
||||
if not property_name:
|
||||
raise ValueError("property_name is required for value conflict detection")
|
||||
return self.detect_value_conflicts(entities, property_name, entity_type)
|
||||
elif method == "type":
|
||||
return self.detect_type_conflicts(entities)
|
||||
elif method == "relationship":
|
||||
relationships = kwargs.get("relationships", [])
|
||||
return self.detect_relationship_conflicts(relationships)
|
||||
elif method == "temporal":
|
||||
return self.detect_temporal_conflicts(entities)
|
||||
elif method == "logical":
|
||||
return self.detect_logical_conflicts(entities)
|
||||
else:
|
||||
# Default to entity-wide detection
|
||||
return self.detect_entity_conflicts(entities, entity_type)
|
||||
|
||||
def detect_value_conflicts(
|
||||
self,
|
||||
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
|
||||
@@ -596,11 +554,6 @@ class ConflictDetector:
|
||||
tracking_id, status="failed", message=str(e)
|
||||
)
|
||||
raise
|
||||
for field_name in fields_to_check:
|
||||
conflicts = self.detect_value_conflicts(entities, field_name, entity_type)
|
||||
all_conflicts.extend(conflicts)
|
||||
|
||||
return all_conflicts
|
||||
|
||||
def _calculate_conflict_confidence(
|
||||
self, values: List[Any], sources: List[Dict[str, Any]]
|
||||
@@ -1252,20 +1205,25 @@ class ConflictDetector:
|
||||
def detect_conflicts(
|
||||
self,
|
||||
entities: Union[List[Dict[str, Any]], Dict[str, Any]],
|
||||
method: str = "all",
|
||||
property_name: Optional[str] = None,
|
||||
entity_type: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> List[Conflict]:
|
||||
"""
|
||||
Detect all conflicts for entities (general method).
|
||||
|
||||
This method detects all types of conflicts: value, type, relationship,
|
||||
temporal, and logical conflicts.
|
||||
Detect conflicts using the specified method.
|
||||
|
||||
Args:
|
||||
entities: List of entity dictionaries or Graph dictionary (containing "entities" key)
|
||||
entities: List of entity dictionaries or Graph dictionary
|
||||
method: Detection method — "all" (default), "value", "property", "type",
|
||||
"relationship", "temporal", "logical", or "entity"
|
||||
property_name: Property name required for ``method="value"`` and ``method="property"``
|
||||
entity_type: Optional entity type filter
|
||||
**kwargs: Extra arguments forwarded to the underlying method
|
||||
(e.g. ``relationships=`` for ``method="relationship"``)
|
||||
|
||||
Returns:
|
||||
List of all detected conflicts
|
||||
List of detected conflicts
|
||||
"""
|
||||
# Handle graph dictionary input
|
||||
if isinstance(entities, dict):
|
||||
@@ -1275,6 +1233,34 @@ class ConflictDetector:
|
||||
# If it's a single entity dict, wrap in list
|
||||
entities = [entities]
|
||||
|
||||
# Dispatch to a specific sub-method when one is requested
|
||||
if method == "value":
|
||||
if not property_name:
|
||||
raise ValueError("property_name is required for method='value'")
|
||||
return self.detect_value_conflicts(entities, property_name, entity_type)
|
||||
elif method == "property":
|
||||
if not property_name:
|
||||
raise ValueError("property_name is required for method='property'")
|
||||
return self.detect_property_conflicts(entities, property_name)
|
||||
elif method == "type":
|
||||
return self.detect_type_conflicts(entities)
|
||||
elif method == "relationship":
|
||||
relationships = kwargs.get("relationships", [])
|
||||
if isinstance(relationships, dict):
|
||||
inner = relationships.get("relationships", relationships)
|
||||
relationships = inner if isinstance(inner, list) else [inner]
|
||||
if not isinstance(relationships, list):
|
||||
relationships = [relationships]
|
||||
return self.detect_relationship_conflicts(relationships)
|
||||
elif method == "temporal":
|
||||
return self.detect_temporal_conflicts(entities)
|
||||
elif method == "logical":
|
||||
return self.detect_logical_conflicts(entities)
|
||||
elif method == "entity":
|
||||
return self.detect_entity_conflicts(entities, entity_type)
|
||||
elif method != "all":
|
||||
raise ValueError(f"Unknown conflict detection method: {method!r}")
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
file=None,
|
||||
module="conflicts",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -100,6 +100,10 @@ class DuplicateDetector:
|
||||
confidence_threshold: float = 0.6,
|
||||
use_clustering: bool = True,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
max_results: Optional[int] = None,
|
||||
top_k_per_entity: Optional[int] = None,
|
||||
min_similarity: Optional[float] = None,
|
||||
sort_by: str = "confidence",
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -115,6 +119,16 @@ class DuplicateDetector:
|
||||
(0.0 to 1.0, default: 0.6)
|
||||
use_clustering: Whether to use clustering for group formation (default: True)
|
||||
config: Configuration dictionary (merged with kwargs)
|
||||
max_results: Hard cap on total candidates returned across all entities.
|
||||
Applied after sorting. ``None`` means no limit.
|
||||
top_k_per_entity: Keep at most this many candidates per entity (by the
|
||||
sort field). ``None`` means no per-entity limit.
|
||||
min_similarity: Additional similarity floor applied on top of
|
||||
``similarity_threshold``. Candidates whose
|
||||
``similarity_score`` is below this value are dropped
|
||||
before ranking. ``None`` means no extra floor.
|
||||
sort_by: Field used for ranking before limits are applied.
|
||||
``"confidence"`` (default) or ``"similarity_score"``.
|
||||
**kwargs: Additional configuration options:
|
||||
- similarity: Configuration for SimilarityCalculator
|
||||
"""
|
||||
@@ -133,6 +147,30 @@ class DuplicateDetector:
|
||||
self.confidence_threshold = confidence_threshold
|
||||
self.use_clustering = use_clustering
|
||||
|
||||
# Result limiting / ranking options — validate at construction time
|
||||
if max_results is not None and (not isinstance(max_results, int) or max_results < 0):
|
||||
raise ValueError(
|
||||
f"max_results must be None or a non-negative int, got {max_results!r}"
|
||||
)
|
||||
if top_k_per_entity is not None and (
|
||||
not isinstance(top_k_per_entity, int) or top_k_per_entity < 0
|
||||
):
|
||||
raise ValueError(
|
||||
f"top_k_per_entity must be None or a non-negative int, got {top_k_per_entity!r}"
|
||||
)
|
||||
if min_similarity is not None and not (0.0 <= min_similarity <= 1.0):
|
||||
raise ValueError(
|
||||
f"min_similarity must be None or a float in [0.0, 1.0], got {min_similarity!r}"
|
||||
)
|
||||
if sort_by not in ("confidence", "similarity_score"):
|
||||
raise ValueError(
|
||||
f"sort_by must be 'confidence' or 'similarity_score', got {sort_by!r}"
|
||||
)
|
||||
self.max_results = max_results
|
||||
self.top_k_per_entity = top_k_per_entity
|
||||
self.min_similarity = min_similarity
|
||||
self.sort_by = sort_by
|
||||
|
||||
# Initialize progress tracker and ensure it's enabled
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
if not self.progress_tracker.enabled:
|
||||
@@ -140,7 +178,9 @@ class DuplicateDetector:
|
||||
|
||||
self.logger.debug(
|
||||
f"Duplicate detector initialized: similarity_threshold={similarity_threshold}, "
|
||||
f"confidence_threshold={confidence_threshold}"
|
||||
f"confidence_threshold={confidence_threshold}, max_results={max_results}, "
|
||||
f"top_k_per_entity={top_k_per_entity}, min_similarity={min_similarity}, "
|
||||
f"sort_by={sort_by!r}"
|
||||
)
|
||||
|
||||
def detect_duplicates(
|
||||
@@ -153,8 +193,10 @@ class DuplicateDetector:
|
||||
Detect duplicate entities from a list.
|
||||
|
||||
This method compares all pairs of entities and identifies duplicates based
|
||||
on similarity scores and confidence thresholds. Returns candidates sorted
|
||||
by confidence (highest first).
|
||||
on similarity scores and confidence thresholds. Results are filtered and
|
||||
ranked by ``_apply_result_limits`` using the instance ``sort_by`` field
|
||||
(default: ``"confidence"``), then capped by ``top_k_per_entity`` and
|
||||
``max_results``.
|
||||
|
||||
Args:
|
||||
entities: List of entity dictionaries to check for duplicates.
|
||||
@@ -163,8 +205,9 @@ class DuplicateDetector:
|
||||
**options: Additional detection options passed to similarity calculator
|
||||
|
||||
Returns:
|
||||
List of DuplicateCandidate objects, sorted by confidence (highest first).
|
||||
Each candidate contains:
|
||||
List of DuplicateCandidate objects sorted by the ``sort_by`` field
|
||||
(highest first, default ``"confidence"``), capped by ``top_k_per_entity``
|
||||
and ``max_results``. Each candidate contains:
|
||||
- entity1, entity2: The duplicate entity pair
|
||||
- similarity_score: Similarity score (0.0 to 1.0)
|
||||
- confidence: Confidence score (0.0 to 1.0)
|
||||
@@ -255,8 +298,8 @@ class DuplicateDetector:
|
||||
message=f"Creating duplicate candidates... {i + 1}/{total_similarities} (remaining: {remaining})",
|
||||
)
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
candidates.sort(key=lambda c: c.confidence, reverse=True)
|
||||
# Sort, filter, and cap results
|
||||
candidates = self._apply_result_limits(candidates)
|
||||
|
||||
self.logger.info(
|
||||
f"Detected {len(candidates)} duplicate candidate(s) "
|
||||
@@ -521,7 +564,9 @@ class DuplicateDetector:
|
||||
|
||||
Returns:
|
||||
List of DuplicateCandidate objects representing duplicates between
|
||||
new and existing entities, sorted by confidence (highest first).
|
||||
new and existing entities, sorted by the ``sort_by`` field (highest
|
||||
first, default ``"confidence"``), capped by ``top_k_per_entity`` and
|
||||
``max_results``.
|
||||
|
||||
Example:
|
||||
>>> new_entities = [{"id": "3", "name": "Apple Corp"}]
|
||||
@@ -600,8 +645,8 @@ class DuplicateDetector:
|
||||
message=f"Comparing entities... {processed}/{total_comparisons} (remaining: {remaining})",
|
||||
)
|
||||
|
||||
# Sort by confidence (highest first)
|
||||
candidates.sort(key=lambda c: c.confidence, reverse=True)
|
||||
# Sort, filter, and cap results
|
||||
candidates = self._apply_result_limits(candidates)
|
||||
|
||||
self.logger.info(
|
||||
f"Incremental detection found {len(candidates)} duplicate candidate(s)"
|
||||
@@ -620,6 +665,57 @@ class DuplicateDetector:
|
||||
)
|
||||
raise
|
||||
|
||||
def _apply_result_limits(
|
||||
self, candidates: List[DuplicateCandidate]
|
||||
) -> List[DuplicateCandidate]:
|
||||
"""
|
||||
Apply min_similarity filter, sort, top_k_per_entity, and max_results cap.
|
||||
|
||||
Order of operations:
|
||||
1. Drop candidates below ``min_similarity`` (if set).
|
||||
2. Sort by ``sort_by`` field descending.
|
||||
3. Apply ``top_k_per_entity``: for each entity id keep only the top-k
|
||||
candidates in which it appears.
|
||||
4. Apply ``max_results`` global cap.
|
||||
"""
|
||||
# 1. min_similarity filter
|
||||
if self.min_similarity is not None:
|
||||
candidates = [
|
||||
c for c in candidates if c.similarity_score >= self.min_similarity
|
||||
]
|
||||
|
||||
# 2. Sort descending by the chosen field
|
||||
candidates.sort(key=lambda c: getattr(c, self.sort_by), reverse=True)
|
||||
|
||||
# 3. top_k_per_entity — keep a candidate if *either* entity is still under
|
||||
# quota; once an entity reaches k it no longer sponsors new candidates.
|
||||
if self.top_k_per_entity is not None:
|
||||
entity_counts: Dict[str, int] = {}
|
||||
kept: List[DuplicateCandidate] = []
|
||||
for c in candidates:
|
||||
nid1 = self._normalize_entity_id(c.entity1)
|
||||
nid2 = self._normalize_entity_id(c.entity2)
|
||||
count1 = entity_counts.get(nid1, 0)
|
||||
count2 = entity_counts.get(nid2, 0)
|
||||
if count1 < self.top_k_per_entity or count2 < self.top_k_per_entity:
|
||||
kept.append(c)
|
||||
entity_counts[nid1] = count1 + 1
|
||||
entity_counts[nid2] = count2 + 1
|
||||
candidates = kept
|
||||
|
||||
# 4. max_results global cap
|
||||
if self.max_results is not None:
|
||||
candidates = candidates[: self.max_results]
|
||||
|
||||
return candidates
|
||||
|
||||
def _normalize_entity_id(self, entity: Any) -> str:
|
||||
"""Return a stable string key for an entity, used as a dict key throughout the class."""
|
||||
raw = self._get_entity_value(entity, "id")
|
||||
if raw is None:
|
||||
raw = id(entity)
|
||||
return str(raw)
|
||||
|
||||
def _get_entity_value(self, entity: Any, key: str, default: Any = None) -> Any:
|
||||
"""Get value from entity dictionary or object safely."""
|
||||
if hasattr(entity, "__dict__"):
|
||||
@@ -715,12 +811,8 @@ class DuplicateDetector:
|
||||
groups = []
|
||||
|
||||
for candidate in candidates:
|
||||
entity1_id = self._get_entity_value(candidate.entity1, "id") or id(
|
||||
candidate.entity1
|
||||
)
|
||||
entity2_id = self._get_entity_value(candidate.entity2, "id") or id(
|
||||
candidate.entity2
|
||||
)
|
||||
entity1_id = self._normalize_entity_id(candidate.entity1)
|
||||
entity2_id = self._normalize_entity_id(candidate.entity2)
|
||||
|
||||
group1 = entity_to_group.get(entity1_id)
|
||||
group2 = entity_to_group.get(entity2_id)
|
||||
@@ -764,7 +856,7 @@ class DuplicateDetector:
|
||||
|
||||
# Update references
|
||||
for entity in group2.entities:
|
||||
entity_id = self._get_entity_value(entity, "id") or id(entity)
|
||||
entity_id = self._normalize_entity_id(entity)
|
||||
entity_to_group[entity_id] = group1
|
||||
|
||||
if group2 in groups:
|
||||
|
||||
@@ -98,6 +98,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
from .routes.enrich import router as enrich_router
|
||||
from .routes.export_import import router as export_import_router
|
||||
from .routes.graph import router as graph_router
|
||||
from .routes.ontology import router as ontology_router
|
||||
from .routes.provenance import router as provenance_router
|
||||
from .routes.sparql import router as sparql_router
|
||||
from .routes.temporal import router as temporal_router
|
||||
@@ -113,6 +114,7 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
|
||||
app.include_router(sparql_router)
|
||||
app.include_router(provenance_router)
|
||||
app.include_router(vocabulary_router)
|
||||
app.include_router(ontology_router)
|
||||
|
||||
_WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +78,66 @@ def _parse_bbox(raw_bbox: Optional[str]) -> Optional[tuple[float, float, float,
|
||||
return min_x, min_y, max_x, max_y
|
||||
|
||||
|
||||
def _coerce_embedding_vector(value: object) -> Optional[List[float]]:
|
||||
if isinstance(value, dict):
|
||||
# Probe keys in priority order: generic first, then framework-specific.
|
||||
# Must stay aligned with the top-level keys in _extract_node_embeddings.
|
||||
for key in ("embedding", "embeddings", "vector", "values", "node2vec", "semantic"):
|
||||
nested = _coerce_embedding_vector(value.get(key))
|
||||
if nested is not None:
|
||||
return nested
|
||||
return None
|
||||
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return None
|
||||
|
||||
vector: List[float] = []
|
||||
for item in value:
|
||||
try:
|
||||
vector.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return vector if vector else None
|
||||
|
||||
|
||||
def _extract_node_embeddings(graph_dict: dict) -> dict[str, List[float]]:
|
||||
# Top-level keys to probe on each entity (and its metadata/properties dicts).
|
||||
# Priority: generic names first, then KG-extras-specific names.
|
||||
# Must stay aligned with the inner probe list in _coerce_embedding_vector.
|
||||
# TODO: cache this per-session graph revision to avoid re-scanning all nodes on every request.
|
||||
embedding_keys = (
|
||||
"embedding",
|
||||
"embeddings",
|
||||
"vector",
|
||||
"node_embedding",
|
||||
"node2vec_embedding",
|
||||
"semantic_embedding",
|
||||
"reasoning_embedding",
|
||||
)
|
||||
|
||||
embeddings: dict[str, List[float]] = {}
|
||||
for entity in graph_dict.get("entities") or graph_dict.get("nodes") or []:
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
node_id = entity.get("id") or entity.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
metadata = entity.get("metadata") if isinstance(entity.get("metadata"), dict) else {}
|
||||
properties = entity.get("properties") if isinstance(entity.get("properties"), dict) else {}
|
||||
|
||||
for key in embedding_keys:
|
||||
vector = _coerce_embedding_vector(
|
||||
entity.get(key, metadata.get(key, properties.get(key)))
|
||||
)
|
||||
if vector is not None:
|
||||
embeddings[str(node_id)] = vector
|
||||
break
|
||||
|
||||
return embeddings
|
||||
|
||||
|
||||
def _node_response(node: dict) -> NodeResponse:
|
||||
return NodeResponse(**node)
|
||||
|
||||
@@ -144,14 +243,14 @@ class _PathAlgorithm(str, Enum):
|
||||
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
async def _find_path_impl(
|
||||
source: str,
|
||||
target: str,
|
||||
algorithm: _PathAlgorithm,
|
||||
directed: bool,
|
||||
session: GraphSession,
|
||||
) -> PathResponse:
|
||||
"""Resolve and enrich a path between two arbitrary graph node ids."""
|
||||
path_finder = session.path_finder
|
||||
if path_finder is None:
|
||||
raise HTTPException(status_code=503, detail="PathFinder not available; KG extras may not be installed.")
|
||||
@@ -163,20 +262,107 @@ async def find_path(
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, node_id, target, directed=directed)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, source, target, directed=directed)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}': {exc}")
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}': {exc}")
|
||||
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if not path_nodes:
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{node_id}' to '{target}'")
|
||||
raise HTTPException(status_code=404, detail=f"No path found from '{source}' to '{target}'")
|
||||
|
||||
total_weight = result.get("total_weight", 0.0) if isinstance(result, dict) else 0.0
|
||||
edge_ids = await asyncio.to_thread(session.resolve_path_edge_ids, path_nodes)
|
||||
|
||||
hop_count = len(path_nodes) - 1 if path_nodes else 0
|
||||
distance_band = classify_path_distance(hop_count)
|
||||
|
||||
# FR-4 enrichment — compute optional fields from existing session analytics
|
||||
confidence_decay: Optional[float] = None
|
||||
bottleneck_node: Optional[str] = None
|
||||
semantic_similarity: Optional[float] = None
|
||||
path_coherence_score: Optional[float] = None
|
||||
alternative_path_count: int = 0
|
||||
|
||||
try:
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
|
||||
# Build edge weight index once in O(E) so each hop lookup is O(1).
|
||||
# graph_dict may use "edges" or "relationships" depending on the graph source.
|
||||
edge_weight_index: dict = {}
|
||||
for _e in graph_dict.get("edges") or graph_dict.get("relationships", []):
|
||||
_s, _t = _e.get("source"), _e.get("target")
|
||||
_w = float(_e.get("weight", 1.0))
|
||||
edge_weight_index[(_s, _t)] = _w
|
||||
if not directed:
|
||||
edge_weight_index.setdefault((_t, _s), _w)
|
||||
|
||||
# Confidence decay — product of edge weights along the path (O(L))
|
||||
decay = 1.0
|
||||
for i in range(len(path_nodes) - 1):
|
||||
decay *= edge_weight_index.get((path_nodes[i], path_nodes[i + 1]), 1.0)
|
||||
confidence_decay = decay
|
||||
|
||||
# Bottleneck — intermediate node with highest betweenness in subgraph
|
||||
intermediates = path_nodes[1:-1] if len(path_nodes) > 2 else []
|
||||
if intermediates and session.centrality is not None:
|
||||
sub_dict = await asyncio.to_thread(session.build_graph_dict, path_nodes)
|
||||
centrality_result = await asyncio.to_thread(
|
||||
session.centrality.calculate_betweenness_centrality, sub_dict
|
||||
)
|
||||
scores = centrality_result.get("betweenness", {}) if isinstance(centrality_result, dict) else {}
|
||||
if scores:
|
||||
bottleneck_node = max(
|
||||
(n for n in intermediates if n in scores),
|
||||
key=lambda n: scores.get(n, 0.0),
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Alternative paths — count simple paths within hop_count + 2
|
||||
if path_finder is not None and hop_count > 0:
|
||||
try:
|
||||
k_paths = await asyncio.to_thread(
|
||||
path_finder.find_k_shortest_paths,
|
||||
graph_dict, source, target, hop_count + 2, directed=directed
|
||||
)
|
||||
alternative_path_count = max(0, len(k_paths) - 1)
|
||||
except Exception as exc:
|
||||
logger.debug("k_shortest_paths unavailable for enrichment: %s", exc)
|
||||
|
||||
# Semantic similarity (source ↔ target)
|
||||
if session.similarity is not None:
|
||||
try:
|
||||
sim_result = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, source, target
|
||||
)
|
||||
if isinstance(sim_result, (int, float)):
|
||||
semantic_similarity = float(sim_result)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_similarity unavailable for enrichment: %s", exc)
|
||||
|
||||
# Path coherence — mean pairwise similarity of consecutive nodes
|
||||
if session.similarity is not None and len(path_nodes) >= 2:
|
||||
try:
|
||||
pair_sims: List[float] = []
|
||||
for i in range(len(path_nodes) - 1):
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity,
|
||||
graph_dict, path_nodes[i], path_nodes[i + 1]
|
||||
)
|
||||
if isinstance(sim, (int, float)):
|
||||
pair_sims.append(float(sim))
|
||||
if pair_sims:
|
||||
path_coherence_score = sum(pair_sims) / len(pair_sims)
|
||||
except Exception as exc:
|
||||
logger.debug("path_coherence unavailable for enrichment: %s", exc)
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("FR-4 enrichment skipped: %s", exc)
|
||||
|
||||
interpretation = _build_interpretation(distance_band, hop_count, bottleneck_node, confidence_decay)
|
||||
|
||||
return PathResponse(
|
||||
source=node_id,
|
||||
source=source,
|
||||
target=target,
|
||||
algorithm=algorithm.value,
|
||||
path=path_nodes,
|
||||
@@ -184,23 +370,268 @@ async def find_path(
|
||||
total_weight=total_weight,
|
||||
directed=directed,
|
||||
hop_count=hop_count,
|
||||
distance_band=classify_path_distance(hop_count),
|
||||
distance_band=distance_band,
|
||||
semantic_similarity=semantic_similarity,
|
||||
path_coherence_score=path_coherence_score,
|
||||
confidence_decay=confidence_decay,
|
||||
bottleneck_node=bottleneck_node,
|
||||
alternative_path_count=alternative_path_count,
|
||||
interpretation=interpretation,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/path", response_model=PathResponse)
|
||||
async def find_path_by_query(
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _find_path_impl(source, target, algorithm, directed, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/path", response_model=PathResponse)
|
||||
async def find_path(
|
||||
node_id: str,
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
algorithm: _PathAlgorithm = Query(_PathAlgorithm.bfs, description="Algorithm: bfs or dijkstra"),
|
||||
directed: bool = Query(True, description="If false, treat edges as undirected for traversal"),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/path?source=...&target=...
|
||||
for slash-safe path lookup.
|
||||
"""
|
||||
return await _find_path_impl(node_id, target, algorithm, directed, session)
|
||||
|
||||
|
||||
@router.post("/search", response_model=SearchResultResponse)
|
||||
async def search_nodes(
|
||||
body: SearchRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
results = await asyncio.to_thread(session.search, body.query, body.limit, body.filters)
|
||||
items = [
|
||||
SearchResultItem(node=_node_response(result.get("node", {})), score=result.get("score", 0.0))
|
||||
for result in results
|
||||
]
|
||||
|
||||
# FR-7 — compute hop distances from anchor when requested
|
||||
hop_by_id: dict = {}
|
||||
if body.anchor_node:
|
||||
neighbors = await asyncio.to_thread(
|
||||
session.graph.get_neighbor_distances,
|
||||
body.anchor_node,
|
||||
hops=body.max_hops if body.max_hops is not None else 10,
|
||||
)
|
||||
hop_by_id = {n.get("id"): n.get("hop") for n in neighbors}
|
||||
hop_by_id[body.anchor_node] = 0
|
||||
|
||||
items: List[SearchResultItem] = []
|
||||
for result in results:
|
||||
node_data = result.get("node", {})
|
||||
node_id = node_data.get("id", "")
|
||||
raw_score = result.get("score", 0.0)
|
||||
|
||||
hop_distance: Optional[int] = hop_by_id.get(node_id) if body.anchor_node else None
|
||||
|
||||
# Drop results beyond max_hops
|
||||
if body.anchor_node and body.max_hops is not None:
|
||||
if hop_distance is None or hop_distance > body.max_hops:
|
||||
continue
|
||||
|
||||
# Compute combined ranking score
|
||||
final_score = raw_score
|
||||
if body.anchor_node and hop_distance is not None:
|
||||
proximity = 1.0 if hop_distance == 0 else 1.0 / hop_distance
|
||||
if body.rank_by == "proximity":
|
||||
final_score = proximity
|
||||
elif body.rank_by == "hybrid":
|
||||
final_score = 0.6 * raw_score + 0.4 * proximity
|
||||
|
||||
items.append(
|
||||
SearchResultItem(
|
||||
node=_node_response(node_data),
|
||||
score=final_score,
|
||||
hop_distance=hop_distance,
|
||||
)
|
||||
)
|
||||
|
||||
if body.rank_by in ("proximity", "hybrid") and body.anchor_node:
|
||||
items.sort(key=lambda item: item.score, reverse=True)
|
||||
|
||||
return SearchResultResponse(results=items, total=len(items), query=body.query)
|
||||
|
||||
|
||||
@router.post("/distance-matrix", response_model=DistanceMatrixResponse)
|
||||
async def distance_matrix(
|
||||
body: DistanceMatrixRequest,
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
if len(body.node_ids) > 50:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Too many nodes: {len(body.node_ids)} requested; maximum is 50 per request.",
|
||||
)
|
||||
|
||||
if body.metric == "semantic" and session.similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="metric='semantic' requires an embedding backend which is not available in this session.",
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
path_finder = session.path_finder
|
||||
|
||||
n = len(body.node_ids)
|
||||
matrix: List[List[Optional[float]]] = [[None] * n for _ in range(n)]
|
||||
unreachable: List[tuple] = []
|
||||
|
||||
for i in range(n):
|
||||
matrix[i][i] = 0.0
|
||||
for j in range(i + 1, n):
|
||||
src, tgt = body.node_ids[i], body.node_ids[j]
|
||||
try:
|
||||
if body.metric == "semantic" and session.similarity is not None:
|
||||
sim = await asyncio.to_thread(
|
||||
session.similarity.cosine_similarity, graph_dict, src, tgt
|
||||
)
|
||||
val = 1.0 - float(sim) if isinstance(sim, (int, float)) else None
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
elif path_finder is not None:
|
||||
path_fn = (
|
||||
path_finder.dijkstra_shortest_path
|
||||
if body.metric == "weighted"
|
||||
else path_finder.bfs_shortest_path
|
||||
)
|
||||
result = await asyncio.to_thread(path_fn, graph_dict, src, tgt)
|
||||
path_nodes = result.get("path", []) if isinstance(result, dict) else (result or [])
|
||||
if path_nodes:
|
||||
val = (
|
||||
float(result.get("total_weight", len(path_nodes) - 1))
|
||||
if body.metric == "weighted"
|
||||
else float(len(path_nodes) - 1)
|
||||
)
|
||||
matrix[i][j] = val
|
||||
matrix[j][i] = val
|
||||
else:
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
except Exception as exc:
|
||||
logger.debug("distance_matrix pair (%s, %s) failed: %s", src, tgt, exc)
|
||||
unreachable.append((src, tgt))
|
||||
unreachable.append((tgt, src))
|
||||
|
||||
elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
|
||||
return DistanceMatrixResponse(
|
||||
nodes=body.node_ids,
|
||||
metric=body.metric,
|
||||
matrix=matrix,
|
||||
unreachable_pairs=unreachable,
|
||||
computation_time_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
|
||||
async def _semantic_neighborhood_impl(
|
||||
node_id: str,
|
||||
top_k: int,
|
||||
min_similarity: float,
|
||||
session: GraphSession,
|
||||
) -> SemanticNeighborhoodResponse:
|
||||
node = await asyncio.to_thread(session.get_node, node_id)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Node '{node_id}' not found")
|
||||
|
||||
similarity = session.similarity
|
||||
if similarity is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity is unavailable for this graph session.",
|
||||
)
|
||||
|
||||
graph_dict = await asyncio.to_thread(session.build_graph_dict)
|
||||
embeddings = _extract_node_embeddings(graph_dict)
|
||||
query_embedding = embeddings.get(node_id)
|
||||
if not embeddings or query_embedding is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity is unavailable because this graph has no node embeddings.",
|
||||
)
|
||||
|
||||
neighbors: List[SemanticNeighborItem] = []
|
||||
try:
|
||||
similar = await asyncio.to_thread(
|
||||
similarity.find_most_similar,
|
||||
embeddings,
|
||||
query_embedding,
|
||||
top_k=top_k * 2,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("semantic_neighborhood similarity search failed: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Semantic similarity search failed for this graph session.",
|
||||
) from exc
|
||||
|
||||
# find_most_similar returns list of (node_id, score) or dicts
|
||||
for item in similar:
|
||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
nid, sim_score = item[0], item[1]
|
||||
elif isinstance(item, dict):
|
||||
nid = item.get("node_id") or item.get("id", "")
|
||||
sim_score = item.get("similarity", item.get("score", 0.0))
|
||||
else:
|
||||
continue
|
||||
if float(sim_score) < min_similarity or nid == node_id:
|
||||
continue
|
||||
neighbor_node = await asyncio.to_thread(session.get_node, nid)
|
||||
if neighbor_node is None:
|
||||
continue
|
||||
neighbors.append(
|
||||
SemanticNeighborItem(
|
||||
id=str(nid),
|
||||
type=neighbor_node.get("type", ""),
|
||||
content=neighbor_node.get("content", ""),
|
||||
similarity=float(sim_score),
|
||||
)
|
||||
)
|
||||
if len(neighbors) >= top_k:
|
||||
break
|
||||
|
||||
return SemanticNeighborhoodResponse(
|
||||
anchor_node=node_id,
|
||||
neighbors=neighbors,
|
||||
total=len(neighbors),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood_by_query(
|
||||
node_id: str = Query(..., description="Anchor node ID"),
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
|
||||
|
||||
|
||||
@router.get("/node/{node_id}/semantic-neighborhood", response_model=SemanticNeighborhoodResponse)
|
||||
async def semantic_neighborhood(
|
||||
node_id: str,
|
||||
top_k: int = Query(20, ge=1, le=200),
|
||||
min_similarity: float = Query(0.0, ge=0.0, le=1.0),
|
||||
session: GraphSession = Depends(get_session),
|
||||
):
|
||||
"""Deprecated path-segment route kept for backward compatibility.
|
||||
|
||||
Node IDs that contain slashes will return 404 because FastAPI decodes
|
||||
%2F before route matching. Use GET /api/graph/semantic-neighborhood?node_id=...
|
||||
for slash-safe semantic neighborhood lookup.
|
||||
"""
|
||||
return await _semantic_neighborhood_impl(node_id, top_k, min_similarity, session)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=GraphStatsResponse)
|
||||
async def graph_stats(
|
||||
session: GraphSession = Depends(get_session),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
@@ -308,3 +324,91 @@ class ProvenanceEdge(BaseModel):
|
||||
class ProvenanceResponse(BaseModel):
|
||||
nodes: List[ProvenanceNode]
|
||||
edges: List[ProvenanceEdge]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-6 — Distance Matrix API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceMatrixRequest(BaseModel):
|
||||
node_ids: List[str]
|
||||
metric: Literal["hops", "weighted", "semantic"] = "hops"
|
||||
|
||||
|
||||
class DistanceMatrixResponse(BaseModel):
|
||||
nodes: List[str]
|
||||
metric: str
|
||||
matrix: List[List[Optional[float]]]
|
||||
unreachable_pairs: List[Tuple[str, str]] = Field(default_factory=list)
|
||||
computation_time_ms: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-3 backend — Semantic Neighborhood
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SemanticNeighborItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
content: str = ""
|
||||
similarity: float
|
||||
hop_distance: Optional[int] = None
|
||||
|
||||
|
||||
class SemanticNeighborhoodResponse(BaseModel):
|
||||
anchor_node: str
|
||||
neighbors: List[SemanticNeighborItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-8 — Causal Distance Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CausalDistanceReport(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
causal_path: List[str]
|
||||
causal_hop_count: int
|
||||
intermediate_decisions: List[str]
|
||||
confidence_decay: float
|
||||
weakest_link: Optional[Dict[str, Any]] = None
|
||||
interpretation: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-9 — Temporal Distance Alerts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceSnapshot(BaseModel):
|
||||
timestamp: datetime
|
||||
hop_count: Optional[int] = None
|
||||
distance_band: str
|
||||
|
||||
|
||||
class DistanceEvent(BaseModel):
|
||||
timestamp: datetime
|
||||
event_type: Literal["convergence", "divergence", "disconnected", "reconnected"]
|
||||
hop_count_before: Optional[int] = None
|
||||
hop_count_after: Optional[int] = None
|
||||
description: str
|
||||
|
||||
|
||||
class DistanceHistoryResponse(BaseModel):
|
||||
source_id: str
|
||||
target_id: str
|
||||
metric: str
|
||||
history: List[DistanceSnapshot]
|
||||
events: List[DistanceEvent]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FR-10 — Distance-Enriched Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DistanceExportRequest(BaseModel):
|
||||
format: Literal["csv", "jsonl"] = "csv"
|
||||
node_subset: Optional[List[str]] = None
|
||||
include: List[str] = Field(
|
||||
default_factory=lambda: ["source_id", "target_id", "hop_count", "distance_band"],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -112,22 +112,18 @@ Example Usage:
|
||||
>>> content = ingest_web("https://example.com", method="url")
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .config import IngestConfig, ingest_config
|
||||
from .db_ingestor import DatabaseConnector, DataExporter, DBIngestor, TableData
|
||||
from .email_ingestor import AttachmentProcessor, EmailData, EmailIngestor
|
||||
from .email_ingestor import EmailParser as EmailIngestorParser
|
||||
from .feed_ingestor import FeedData, FeedIngestor, FeedItem, FeedMonitor, FeedParser
|
||||
from .file_ingestor import (
|
||||
CloudStorageIngestor,
|
||||
FileIngestor,
|
||||
FileObject,
|
||||
FileTypeDetector,
|
||||
)
|
||||
from .mcp_client import MCPClient, MCPResource, MCPTool
|
||||
from .mcp_ingestor import MCPData, MCPIngestor
|
||||
from .methods import (
|
||||
get_ingest_method,
|
||||
ingest,
|
||||
@@ -143,34 +139,99 @@ from .methods import (
|
||||
list_available_methods,
|
||||
)
|
||||
from .registry import MethodRegistry, method_registry
|
||||
from .repo_ingestor import (
|
||||
CodeExtractor,
|
||||
CodeFile,
|
||||
CommitInfo,
|
||||
GitAnalyzer,
|
||||
RepoIngestor,
|
||||
)
|
||||
from .stream_ingestor import (
|
||||
KafkaProcessor,
|
||||
KinesisProcessor,
|
||||
PulsarProcessor,
|
||||
RabbitMQProcessor,
|
||||
StreamIngestor,
|
||||
StreamMessage,
|
||||
StreamMonitor,
|
||||
StreamProcessor,
|
||||
)
|
||||
from .web_ingestor import (
|
||||
ContentExtractor,
|
||||
RateLimiter,
|
||||
RobotsChecker,
|
||||
SitemapCrawler,
|
||||
WebContent,
|
||||
WebIngestor,
|
||||
)
|
||||
|
||||
from .ontology_ingestor import OntologyData, OntologyIngestor
|
||||
from .snowflake_ingestor import SnowflakeConnector, SnowflakeData, SnowflakeIngestor
|
||||
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# Web ingestion
|
||||
"WebIngestor": (".web_ingestor", "WebIngestor"),
|
||||
"WebContent": (".web_ingestor", "WebContent"),
|
||||
"RateLimiter": (".web_ingestor", "RateLimiter"),
|
||||
"RobotsChecker": (".web_ingestor", "RobotsChecker"),
|
||||
"ContentExtractor": (".web_ingestor", "ContentExtractor"),
|
||||
"SitemapCrawler": (".web_ingestor", "SitemapCrawler"),
|
||||
# Feed ingestion
|
||||
"FeedIngestor": (".feed_ingestor", "FeedIngestor"),
|
||||
"FeedItem": (".feed_ingestor", "FeedItem"),
|
||||
"FeedData": (".feed_ingestor", "FeedData"),
|
||||
"FeedParser": (".feed_ingestor", "FeedParser"),
|
||||
"FeedMonitor": (".feed_ingestor", "FeedMonitor"),
|
||||
# Stream ingestion
|
||||
"StreamIngestor": (".stream_ingestor", "StreamIngestor"),
|
||||
"StreamMessage": (".stream_ingestor", "StreamMessage"),
|
||||
"StreamProcessor": (".stream_ingestor", "StreamProcessor"),
|
||||
"KafkaProcessor": (".stream_ingestor", "KafkaProcessor"),
|
||||
"RabbitMQProcessor": (".stream_ingestor", "RabbitMQProcessor"),
|
||||
"KinesisProcessor": (".stream_ingestor", "KinesisProcessor"),
|
||||
"PulsarProcessor": (".stream_ingestor", "PulsarProcessor"),
|
||||
"StreamMonitor": (".stream_ingestor", "StreamMonitor"),
|
||||
# Repository ingestion
|
||||
"RepoIngestor": (".repo_ingestor", "RepoIngestor"),
|
||||
"CodeFile": (".repo_ingestor", "CodeFile"),
|
||||
"CommitInfo": (".repo_ingestor", "CommitInfo"),
|
||||
"CodeExtractor": (".repo_ingestor", "CodeExtractor"),
|
||||
"GitAnalyzer": (".repo_ingestor", "GitAnalyzer"),
|
||||
# Email ingestion
|
||||
"EmailIngestor": (".email_ingestor", "EmailIngestor"),
|
||||
"EmailData": (".email_ingestor", "EmailData"),
|
||||
"AttachmentProcessor": (".email_ingestor", "AttachmentProcessor"),
|
||||
"EmailIngestorParser": (".email_ingestor", "EmailParser"),
|
||||
# Database ingestion
|
||||
"DBIngestor": (".db_ingestor", "DBIngestor"),
|
||||
"TableData": (".db_ingestor", "TableData"),
|
||||
"DatabaseConnector": (".db_ingestor", "DatabaseConnector"),
|
||||
"DataExporter": (".db_ingestor", "DataExporter"),
|
||||
# MCP ingestion
|
||||
"MCPIngestor": (".mcp_ingestor", "MCPIngestor"),
|
||||
"MCPData": (".mcp_ingestor", "MCPData"),
|
||||
"MCPClient": (".mcp_client", "MCPClient"),
|
||||
"MCPResource": (".mcp_client", "MCPResource"),
|
||||
"MCPTool": (".mcp_client", "MCPTool"),
|
||||
# Ontology ingestion
|
||||
"OntologyIngestor": (".ontology_ingestor", "OntologyIngestor"),
|
||||
"OntologyData": (".ontology_ingestor", "OntologyData"),
|
||||
# Snowflake ingestion
|
||||
"SnowflakeIngestor": (".snowflake_ingestor", "SnowflakeIngestor"),
|
||||
"SnowflakeData": (".snowflake_ingestor", "SnowflakeData"),
|
||||
"SnowflakeConnector": (".snowflake_ingestor", "SnowflakeConnector"),
|
||||
}
|
||||
|
||||
_OPTIONAL_DEPENDENCY_MESSAGES = {
|
||||
".repo_ingestor": (
|
||||
"Repository ingestion requires optional dependency 'GitPython'. "
|
||||
"Install it before importing RepoIngestor or using ingest_repository()."
|
||||
),
|
||||
".web_ingestor": (
|
||||
"Web ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing WebIngestor or using ingest_web()."
|
||||
),
|
||||
".feed_ingestor": (
|
||||
"Feed ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing FeedIngestor or using ingest_feed()."
|
||||
),
|
||||
".email_ingestor": (
|
||||
"Email ingestion requires optional dependency 'beautifulsoup4'. "
|
||||
"Install it before importing EmailIngestor or using ingest_email()."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Load optional ingestion backends only when callers request them."""
|
||||
if name not in _LAZY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
module_name, attr_name = _LAZY_EXPORTS[name]
|
||||
try:
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
except ModuleNotFoundError as exc:
|
||||
message = _OPTIONAL_DEPENDENCY_MESSAGES.get(module_name)
|
||||
missing_name = getattr(exc, "name", None)
|
||||
if message and missing_name in {"git", "bs4"}:
|
||||
raise ImportError(message) from exc
|
||||
raise
|
||||
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
__all__ = [
|
||||
# File ingestion
|
||||
|
||||
@@ -139,26 +139,32 @@ Example Usage:
|
||||
>>> content = ingest_web("https://example.com", method="url")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from ..utils.exceptions import ConfigurationError, ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from .config import ingest_config
|
||||
from .db_ingestor import DBIngestor, TableData
|
||||
from .email_ingestor import EmailData, EmailIngestor
|
||||
from .feed_ingestor import FeedData, FeedIngestor
|
||||
from .file_ingestor import FileIngestor, FileObject
|
||||
from .mcp_ingestor import MCPData, MCPIngestor
|
||||
from .ontology_ingestor import OntologyData, OntologyIngestor
|
||||
from .registry import method_registry
|
||||
from .repo_ingestor import RepoIngestor
|
||||
from .stream_ingestor import StreamIngestor, StreamProcessor
|
||||
from .web_ingestor import WebContent, WebIngestor
|
||||
|
||||
logger = get_logger("ingest_methods")
|
||||
|
||||
|
||||
def _missing_optional_dependency(feature: str, package: str) -> ConfigurationError:
|
||||
return ConfigurationError(
|
||||
f"{feature} requires optional dependency '{package}'. "
|
||||
f"Install it before using this ingestion backend."
|
||||
)
|
||||
|
||||
|
||||
def _is_missing_dependency(exc: ModuleNotFoundError, *dependency_names: str) -> bool:
|
||||
missing_name = getattr(exc, "name", None)
|
||||
return missing_name in dependency_names
|
||||
|
||||
|
||||
def ingest_file(
|
||||
source: Union[str, Path, List[Union[str, Path]]], method: str = "file", **kwargs
|
||||
) -> Union[FileObject, List[FileObject], Dict[str, Any]]:
|
||||
@@ -259,6 +265,16 @@ def ingest_web(
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
from .web_ingestor import WebIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "bs4"):
|
||||
raise _missing_optional_dependency(
|
||||
"Web ingestion",
|
||||
"beautifulsoup4",
|
||||
) from exc
|
||||
raise
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("web")
|
||||
config.update(kwargs)
|
||||
@@ -278,6 +294,8 @@ def ingest_web(
|
||||
# Default: try as URL
|
||||
return ingestor.ingest_url(source, **kwargs)
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest web: {e}")
|
||||
raise
|
||||
@@ -318,6 +336,16 @@ def ingest_feed(
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
from .feed_ingestor import FeedIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "bs4"):
|
||||
raise _missing_optional_dependency(
|
||||
"Feed ingestion",
|
||||
"beautifulsoup4",
|
||||
) from exc
|
||||
raise
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("feed")
|
||||
config.update(kwargs)
|
||||
@@ -332,6 +360,8 @@ def ingest_feed(
|
||||
else:
|
||||
return ingestor.ingest_feed(source, **kwargs)
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest feed: {e}")
|
||||
raise
|
||||
@@ -375,6 +405,8 @@ def ingest_stream(
|
||||
)
|
||||
|
||||
try:
|
||||
from .stream_ingestor import StreamIngestor
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("stream")
|
||||
config.update(kwargs)
|
||||
@@ -447,6 +479,13 @@ def ingest_repository(
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
from .repo_ingestor import RepoIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "git"):
|
||||
raise _missing_optional_dependency("Repository ingestion", "GitPython") from exc
|
||||
raise
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("repo")
|
||||
config.update(kwargs)
|
||||
@@ -464,6 +503,8 @@ def ingest_repository(
|
||||
# Default: ingest repository
|
||||
return ingestor.ingest_repository(source, **kwargs)
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest repository: {e}")
|
||||
raise
|
||||
@@ -505,6 +546,16 @@ def ingest_email(
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
from .email_ingestor import EmailIngestor
|
||||
except ModuleNotFoundError as exc:
|
||||
if _is_missing_dependency(exc, "bs4"):
|
||||
raise _missing_optional_dependency(
|
||||
"Email ingestion",
|
||||
"beautifulsoup4",
|
||||
) from exc
|
||||
raise
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("email")
|
||||
config.update(kwargs)
|
||||
@@ -533,6 +584,8 @@ def ingest_email(
|
||||
else:
|
||||
raise ProcessingError(f"Unknown email method: {method}")
|
||||
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest email: {e}")
|
||||
raise
|
||||
@@ -572,6 +625,8 @@ def ingest_ontology(
|
||||
)
|
||||
|
||||
try:
|
||||
from .ontology_ingestor import OntologyIngestor
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("ontology")
|
||||
config.update(kwargs)
|
||||
@@ -636,6 +691,8 @@ def ingest_database(
|
||||
)
|
||||
|
||||
try:
|
||||
from .db_ingestor import DBIngestor
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("db")
|
||||
config.update(kwargs)
|
||||
@@ -728,6 +785,8 @@ def ingest_mcp(
|
||||
)
|
||||
|
||||
try:
|
||||
from .mcp_ingestor import MCPIngestor
|
||||
|
||||
# Get config
|
||||
config = ingest_config.get_method_config("mcp")
|
||||
config.update(kwargs)
|
||||
|
||||
@@ -11,6 +11,15 @@ Usage
|
||||
Configure in your tool's MCP settings:
|
||||
|
||||
Claude Desktop / Windsurf / Cline / Continue / VS Code:
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
"command": "semantica-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Or using python -m:
|
||||
{
|
||||
"mcpServers": {
|
||||
"semantica": {
|
||||
@@ -20,7 +29,9 @@ Configure in your tool's MCP settings:
|
||||
}
|
||||
}
|
||||
|
||||
Run directly:
|
||||
Run directly for testing:
|
||||
semantica-mcp
|
||||
# or
|
||||
python -m semantica.mcp_server
|
||||
|
||||
Environment variables:
|
||||
@@ -71,9 +82,7 @@ def _tool_extract_entities(args: dict) -> dict:
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
from semantica.semantic_extract import NamedEntityRecognizer
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
_result_cache.clear()
|
||||
entities = NamedEntityRecognizer().extract(text)
|
||||
entities = NamedEntityRecognizer().extract_entities(text)
|
||||
return {
|
||||
"entities": [
|
||||
{"label": getattr(e, "label", str(e)),
|
||||
@@ -91,10 +100,8 @@ def _tool_extract_relations(args: dict) -> dict:
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
from semantica.semantic_extract import RelationExtractor, TripletExtractor
|
||||
from semantica.semantic_extract.cache import _result_cache
|
||||
_result_cache.clear()
|
||||
relations = RelationExtractor().extract(text)
|
||||
triplets = TripletExtractor().extract(text)
|
||||
relations = RelationExtractor().extract_relations(text)
|
||||
triplets = TripletExtractor().extract_triplets(text)
|
||||
return {
|
||||
"relations": [
|
||||
{"source": getattr(r, "source", None),
|
||||
@@ -600,7 +607,3 @@ def _run_stdio():
|
||||
|
||||
def main():
|
||||
_run_stdio()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Entry point for python -m semantica.mcp_server
|
||||
"""
|
||||
|
||||
from semantica.mcp_server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -47,73 +47,97 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import Config, config
|
||||
from .coreference_resolver import (
|
||||
CoreferenceChain,
|
||||
CoreferenceChainBuilder,
|
||||
CoreferenceResolver,
|
||||
EntityCoreferenceDetector,
|
||||
Mention,
|
||||
PronounResolver,
|
||||
)
|
||||
from .event_detector import (
|
||||
Event,
|
||||
EventClassifier,
|
||||
EventDetector,
|
||||
EventRelationshipExtractor,
|
||||
TemporalEventProcessor,
|
||||
)
|
||||
from .extraction_validator import ExtractionValidator, ValidationResult
|
||||
from .llm_extraction import LLMEnhancer, LLMExtraction, LLMResponse
|
||||
from .methods import get_entity_method, get_relation_method, get_triplet_method
|
||||
from .named_entity_recognizer import (
|
||||
CustomEntityDetector,
|
||||
EntityClassifier,
|
||||
EntityConfidenceScorer,
|
||||
NamedEntityRecognizer,
|
||||
)
|
||||
from .ner_extractor import Entity, NERExtractor
|
||||
from .providers import (
|
||||
AnthropicProvider,
|
||||
BaseProvider,
|
||||
GeminiProvider,
|
||||
GroqProvider,
|
||||
HuggingFaceLLMProvider,
|
||||
HuggingFaceModelLoader,
|
||||
OllamaProvider,
|
||||
OpenAIProvider,
|
||||
create_provider,
|
||||
)
|
||||
from .registry import (
|
||||
MethodRegistry,
|
||||
ProviderRegistry,
|
||||
method_registry,
|
||||
provider_registry,
|
||||
)
|
||||
from .relation_extractor import Relation, RelationExtractor
|
||||
from .semantic_analyzer import (
|
||||
RoleLabeler,
|
||||
SemanticAnalyzer,
|
||||
SemanticCluster,
|
||||
SemanticClusterer,
|
||||
SemanticRole,
|
||||
SimilarityAnalyzer,
|
||||
)
|
||||
from .semantic_network_extractor import (
|
||||
SemanticEdge,
|
||||
SemanticNetwork,
|
||||
SemanticNetworkExtractor,
|
||||
SemanticNode,
|
||||
)
|
||||
from .triplet_extractor import (
|
||||
RDFSerializer,
|
||||
Triplet,
|
||||
TripletExtractor,
|
||||
TripletQualityChecker,
|
||||
TripletValidator,
|
||||
)
|
||||
import importlib
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
_LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# Named Entity Recognition
|
||||
"NamedEntityRecognizer": (".named_entity_recognizer", "NamedEntityRecognizer"),
|
||||
"EntityClassifier": (".named_entity_recognizer", "EntityClassifier"),
|
||||
"EntityConfidenceScorer": (".named_entity_recognizer", "EntityConfidenceScorer"),
|
||||
"CustomEntityDetector": (".named_entity_recognizer", "CustomEntityDetector"),
|
||||
"NERExtractor": (".ner_extractor", "NERExtractor"),
|
||||
"Entity": (".types", "Entity"),
|
||||
# Relation Extraction
|
||||
"RelationExtractor": (".relation_extractor", "RelationExtractor"),
|
||||
"Relation": (".types", "Relation"),
|
||||
# Event Detection
|
||||
"EventDetector": (".event_detector", "EventDetector"),
|
||||
"Event": (".event_detector", "Event"),
|
||||
"EventClassifier": (".event_detector", "EventClassifier"),
|
||||
"TemporalEventProcessor": (".event_detector", "TemporalEventProcessor"),
|
||||
"EventRelationshipExtractor": (".event_detector", "EventRelationshipExtractor"),
|
||||
# Coreference Resolution
|
||||
"CoreferenceResolver": (".coreference_resolver", "CoreferenceResolver"),
|
||||
"Mention": (".coreference_resolver", "Mention"),
|
||||
"CoreferenceChain": (".coreference_resolver", "CoreferenceChain"),
|
||||
"PronounResolver": (".coreference_resolver", "PronounResolver"),
|
||||
"EntityCoreferenceDetector": (".coreference_resolver", "EntityCoreferenceDetector"),
|
||||
"CoreferenceChainBuilder": (".coreference_resolver", "CoreferenceChainBuilder"),
|
||||
# Triplet Extraction
|
||||
"TripletExtractor": (".triplet_extractor", "TripletExtractor"),
|
||||
"TripleExtractor": (".triplet_extractor", "TripletExtractor"),
|
||||
"Triplet": (".types", "Triplet"),
|
||||
"TripletValidator": (".triplet_extractor", "TripletValidator"),
|
||||
"RDFSerializer": (".triplet_extractor", "RDFSerializer"),
|
||||
"TripletQualityChecker": (".triplet_extractor", "TripletQualityChecker"),
|
||||
# Semantic Analysis
|
||||
"SemanticAnalyzer": (".semantic_analyzer", "SemanticAnalyzer"),
|
||||
"SemanticRole": (".semantic_analyzer", "SemanticRole"),
|
||||
"SemanticCluster": (".semantic_analyzer", "SemanticCluster"),
|
||||
"SimilarityAnalyzer": (".semantic_analyzer", "SimilarityAnalyzer"),
|
||||
"RoleLabeler": (".semantic_analyzer", "RoleLabeler"),
|
||||
"SemanticClusterer": (".semantic_analyzer", "SemanticClusterer"),
|
||||
# Semantic Network
|
||||
"SemanticNetworkExtractor": (".semantic_network_extractor", "SemanticNetworkExtractor"),
|
||||
"SemanticNode": (".semantic_network_extractor", "SemanticNode"),
|
||||
"SemanticEdge": (".semantic_network_extractor", "SemanticEdge"),
|
||||
"SemanticNetwork": (".semantic_network_extractor", "SemanticNetwork"),
|
||||
# LLM Enhancement
|
||||
"LLMExtraction": (".llm_extraction", "LLMExtraction"),
|
||||
"LLMEnhancer": (".llm_extraction", "LLMEnhancer"),
|
||||
"LLMResponse": (".llm_extraction", "LLMResponse"),
|
||||
# Validation
|
||||
"ExtractionValidator": (".extraction_validator", "ExtractionValidator"),
|
||||
"ValidationResult": (".extraction_validator", "ValidationResult"),
|
||||
# Providers
|
||||
"BaseProvider": (".providers", "BaseProvider"),
|
||||
"OpenAIProvider": (".providers", "OpenAIProvider"),
|
||||
"GeminiProvider": (".providers", "GeminiProvider"),
|
||||
"GroqProvider": (".providers", "GroqProvider"),
|
||||
"AnthropicProvider": (".providers", "AnthropicProvider"),
|
||||
"OllamaProvider": (".providers", "OllamaProvider"),
|
||||
"HuggingFaceLLMProvider": (".providers", "HuggingFaceLLMProvider"),
|
||||
"HuggingFaceModelLoader": (".providers", "HuggingFaceModelLoader"),
|
||||
"create_provider": (".providers", "create_provider"),
|
||||
# Registry
|
||||
"ProviderRegistry": (".registry", "ProviderRegistry"),
|
||||
"MethodRegistry": (".registry", "MethodRegistry"),
|
||||
"provider_registry": (".registry", "provider_registry"),
|
||||
"method_registry": (".registry", "method_registry"),
|
||||
# Config
|
||||
"Config": (".config", "Config"),
|
||||
"config": (".config", "config"),
|
||||
# Methods
|
||||
"get_entity_method": (".methods", "get_entity_method"),
|
||||
"get_relation_method": (".methods", "get_relation_method"),
|
||||
"get_triplet_method": (".methods", "get_triplet_method"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazily load semantic extraction exports to avoid import cycles and optional deps."""
|
||||
if name not in _LAZY_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
module_name, attr_name = _LAZY_EXPORTS[name]
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
__all__ = [
|
||||
# Named Entity Recognition
|
||||
|
||||
@@ -113,13 +113,11 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from .ner_extractor import Entity
|
||||
from .providers import HuggingFaceModelLoader, create_provider
|
||||
from .registry import method_registry
|
||||
from .relation_extractor import Relation
|
||||
from .triplet_extractor import Triplet
|
||||
from .cache import ExtractionCache
|
||||
from .config import config
|
||||
from .types import Entity, Relation, Triplet
|
||||
|
||||
try:
|
||||
from .schemas import (
|
||||
|
||||
@@ -70,29 +70,17 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.helpers import safe_import
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .types import Entity
|
||||
|
||||
spacy, SPACY_AVAILABLE = safe_import("spacy")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
"""Entity representation."""
|
||||
|
||||
text: str
|
||||
label: str
|
||||
start_char: int
|
||||
end_char: int
|
||||
confidence: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class NERExtractor:
|
||||
"""Named Entity Recognition extractor."""
|
||||
|
||||
|
||||
@@ -70,25 +70,12 @@ License: MIT
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ..utils.exceptions import ProcessingError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .ner_extractor import Entity
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relation:
|
||||
"""Relation representation."""
|
||||
|
||||
subject: Entity
|
||||
predicate: str
|
||||
object: Entity
|
||||
confidence: float = 1.0
|
||||
context: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
from .types import Entity, Relation
|
||||
|
||||
|
||||
class RelationExtractor:
|
||||
|
||||
@@ -70,34 +70,13 @@ Author: Semantica Contributors
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
from .ner_extractor import Entity
|
||||
from .relation_extractor import Relation
|
||||
|
||||
|
||||
@dataclass
|
||||
class Triplet:
|
||||
"""RDF triplet representation."""
|
||||
|
||||
subject: str
|
||||
predicate: str
|
||||
object: str
|
||||
confidence: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get attribute value like a dictionary."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get item like a dictionary."""
|
||||
return getattr(self, key)
|
||||
from .types import Entity, Relation, Triplet
|
||||
|
||||
|
||||
class TripletExtractor:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Shared semantic extraction data types.
|
||||
|
||||
These lightweight dataclasses live outside the extractor implementations so
|
||||
method dispatchers and extractors can share result models without import cycles.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
"""Entity representation."""
|
||||
|
||||
text: str
|
||||
label: str
|
||||
start_char: int
|
||||
end_char: int
|
||||
confidence: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relation:
|
||||
"""Relation representation."""
|
||||
|
||||
subject: Entity
|
||||
predicate: str
|
||||
object: Entity
|
||||
confidence: float = 1.0
|
||||
context: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Triplet:
|
||||
"""RDF triplet representation."""
|
||||
|
||||
subject: str
|
||||
predicate: str
|
||||
object: str
|
||||
confidence: float = 1.0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get attribute value like a dictionary."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get item like a dictionary."""
|
||||
return getattr(self, key)
|
||||
+22
-8
@@ -152,10 +152,11 @@ if EXPLORER_AVAILABLE:
|
||||
enrich,
|
||||
export_import,
|
||||
graph,
|
||||
ontology,
|
||||
temporal,
|
||||
vocabulary,
|
||||
provenance,
|
||||
sparql
|
||||
provenance,
|
||||
sparql
|
||||
)
|
||||
|
||||
app.include_router(analytics.router)
|
||||
@@ -164,12 +165,13 @@ if EXPLORER_AVAILABLE:
|
||||
app.include_router(enrich.router)
|
||||
app.include_router(export_import.router)
|
||||
app.include_router(graph.router)
|
||||
app.include_router(ontology.router)
|
||||
app.include_router(temporal.router)
|
||||
app.include_router(vocabulary.router)
|
||||
app.include_router(provenance.router)
|
||||
app.include_router(sparql.router)
|
||||
app.include_router(provenance.router)
|
||||
app.include_router(sparql.router)
|
||||
|
||||
logging.info("Explorer, Vocabulary, SPARQL, and Provenance API routes successfully mounted.")
|
||||
logging.info("Explorer, Vocabulary, SPARQL, Provenance, and Ontology API routes successfully mounted.")
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to mount explorer routes: {exc}")
|
||||
else:
|
||||
@@ -188,10 +190,22 @@ async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="API route not found")
|
||||
|
||||
# Root path — serve index.html if built, otherwise a welcome JSON response
|
||||
if full_path in ("", "/"):
|
||||
index_file = STATIC_DIR / "index.html"
|
||||
if index_file.is_file():
|
||||
return FileResponse(index_file)
|
||||
return JSONResponse({
|
||||
"name": "Semantica Knowledge Explorer",
|
||||
"version": __version__,
|
||||
"message": "Welcome to Semantica. The frontend is not built yet — run `npm run build` inside the explorer/ directory, or open the Vite dev server at http://localhost:5173.",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
})
|
||||
|
||||
normalized_path = os.path.normpath(full_path)
|
||||
if (
|
||||
normalized_path in ("", ".")
|
||||
or os.path.isabs(normalized_path)
|
||||
os.path.isabs(normalized_path)
|
||||
or normalized_path == ".."
|
||||
or normalized_path.startswith(".." + os.sep)
|
||||
):
|
||||
@@ -200,7 +214,7 @@ async def serve_spa(full_path: str):
|
||||
# Ensure join remains relative to STATIC_DIR even if input includes leading separators
|
||||
safe_rel_path = normalized_path.lstrip("/\\")
|
||||
rel_parts = Path(safe_rel_path).parts
|
||||
if any(part in ("", ".", "..") for part in rel_parts):
|
||||
if any(part in (".", "..") for part in rel_parts):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
static_dir_resolved = STATIC_DIR.resolve()
|
||||
|
||||
@@ -253,21 +253,21 @@ class ConsoleProgressDisplay(ProgressDisplay):
|
||||
# If we have pipeline items, show all of them
|
||||
if pipeline_items:
|
||||
# Clear and show all pipeline items
|
||||
sys.stdout.write("\r" + " " * 150 + "\r")
|
||||
|
||||
self._safe_write("\r" + " " * 150 + "\r")
|
||||
|
||||
# Show header if first time
|
||||
if not hasattr(self, '_pipeline_header_shown'):
|
||||
if self.use_emoji:
|
||||
sys.stdout.write("🧠 Semantica - 📊 Current Progress\n")
|
||||
self._safe_write("🧠 Semantica - 📊 Current Progress\n")
|
||||
else:
|
||||
sys.stdout.write("Semantica - Current Progress\n")
|
||||
sys.stdout.write("=" * 150 + "\n")
|
||||
self._safe_write("Semantica - Current Progress\n")
|
||||
self._safe_write("=" * 150 + "\n")
|
||||
self._pipeline_header_shown = True
|
||||
|
||||
|
||||
# Display all pipeline items
|
||||
for pipeline_item in pipeline_items:
|
||||
self._display_item_line(pipeline_item)
|
||||
sys.stdout.write("\n")
|
||||
self._safe_write("\n")
|
||||
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Targeted regression tests for all 13 Qodo review fixes on the Distance Intelligence PR."""
|
||||
import re
|
||||
import inspect
|
||||
|
||||
|
||||
# ── bug_003: include_distance_metadata=False is the backward-compat default ───
|
||||
|
||||
def test_bug003_metadata_absent_by_default():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A")
|
||||
assert len(neighbors) == 1
|
||||
assert "hop" in neighbors[0]
|
||||
assert "distance_band" not in neighbors[0], (
|
||||
f"distance_band should be absent by default; got keys: {list(neighbors[0].keys())}"
|
||||
)
|
||||
assert "confidence_decay" not in neighbors[0]
|
||||
assert "path_to_anchor" not in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_metadata_present_with_flag():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related")
|
||||
neighbors = g.get_neighbors("A", include_distance_metadata=True)
|
||||
assert len(neighbors) == 1
|
||||
assert "distance_band" in neighbors[0]
|
||||
assert "confidence_decay" in neighbors[0]
|
||||
assert "path_to_anchor" in neighbors[0]
|
||||
|
||||
|
||||
def test_bug003_get_neighbor_distances_still_works():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "test")
|
||||
g.add_node("B", "test")
|
||||
g.add_edge("A", "B", "related", weight=0.9)
|
||||
nd = g.get_neighbor_distances("A")
|
||||
assert len(nd) == 1
|
||||
assert nd[0]["distance_band"] == "direct"
|
||||
assert abs(nd[0]["confidence_decay"] - 0.9) < 1e-9
|
||||
assert "path_to_anchor" in nd[0]
|
||||
|
||||
|
||||
# ── bug_004: weakest_link standardized to edge_weight key ─────────────────────
|
||||
|
||||
def test_bug004_weakest_link_uses_edge_weight_key():
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
g = ContextGraph()
|
||||
g.add_node("A", "decision")
|
||||
g.add_node("B", "decision")
|
||||
g.add_node("C", "decision")
|
||||
g.add_edge("A", "B", "causes", weight=0.8)
|
||||
g.add_edge("B", "C", "causes", weight=0.5)
|
||||
analyzer = CausalChainAnalyzer(g)
|
||||
report = analyzer.interpret_causal_distance("A", "C")
|
||||
wl = report.get("weakest_link")
|
||||
assert wl is not None, "weakest_link must be set for a 2-hop causal path"
|
||||
assert "edge_weight" in wl, f"Expected edge_weight key, got: {list(wl.keys())}"
|
||||
assert "weight" not in wl, f"Old key 'weight' should be absent; got: {list(wl.keys())}"
|
||||
assert wl["edge_weight"] == 0.5
|
||||
|
||||
|
||||
def test_bug004_causal_distance_report_schema_validates():
|
||||
from semantica.explorer.schemas import CausalDistanceReport
|
||||
report = CausalDistanceReport(
|
||||
source_id="A",
|
||||
target_id="C",
|
||||
causal_path=["A", "B", "C"],
|
||||
causal_hop_count=2,
|
||||
intermediate_decisions=["B"],
|
||||
confidence_decay=0.4,
|
||||
weakest_link={"source": "A", "target": "B", "edge_weight": 0.5},
|
||||
interpretation="Test path",
|
||||
)
|
||||
assert report.weakest_link["edge_weight"] == 0.5
|
||||
|
||||
|
||||
# ── qual_003: _distance_band static methods removed; classify_path_distance used ─
|
||||
|
||||
def test_qual003_distance_band_removed_from_causal_analyzer():
|
||||
from semantica.context.causal_analyzer import CausalChainAnalyzer
|
||||
assert not hasattr(CausalChainAnalyzer, "_distance_band")
|
||||
ca_src = inspect.getsource(CausalChainAnalyzer)
|
||||
assert "def _distance_band" not in ca_src
|
||||
assert "classify_path_distance" in ca_src
|
||||
|
||||
|
||||
def test_qual003_distance_band_removed_from_agent_context():
|
||||
import semantica.context.agent_context as ac_mod
|
||||
ac_src = inspect.getsource(ac_mod)
|
||||
assert "def _distance_band" not in ac_src
|
||||
assert "classify_path_distance" in ac_src
|
||||
|
||||
|
||||
# ── bug_005: timedelta arithmetic — no timetuple reconstruction ───────────────
|
||||
|
||||
def test_bug005_no_timetuple_hack_in_distance_history():
|
||||
from semantica.explorer.routes import temporal
|
||||
src = inspect.getsource(temporal.distance_history)
|
||||
assert "timetuple" not in src, "Old timetuple hack should be gone"
|
||||
assert "__import__" not in src, "Dynamic import hack should be gone"
|
||||
assert "timedelta(seconds" in src
|
||||
|
||||
|
||||
# ── sec_001: node_subset capped at 200 ────────────────────────────────────────
|
||||
|
||||
def test_sec001_node_subset_limit_constant_exists():
|
||||
from semantica.explorer.routes.export_import import _DISTANCE_EXPORT_MAX_NODES
|
||||
assert _DISTANCE_EXPORT_MAX_NODES == 200
|
||||
|
||||
|
||||
def test_sec001_export_endpoint_validates_subset_size():
|
||||
from semantica.explorer.routes import export_import
|
||||
src = inspect.getsource(export_import.export_distance_enriched)
|
||||
assert "_DISTANCE_EXPORT_MAX_NODES" in src
|
||||
assert "status_code=413" in src
|
||||
|
||||
|
||||
# ── sec_002: distance matrix upper-triangle only ──────────────────────────────
|
||||
|
||||
def test_sec002_distance_matrix_upper_triangle_loop():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(graph.distance_matrix)
|
||||
assert "range(i + 1, n)" in src, "Should use upper-triangle loop"
|
||||
assert "matrix[j][i]" in src, "Should mirror lower triangle"
|
||||
|
||||
|
||||
# ── bug_006: O(L) edge weight index built once ────────────────────────────────
|
||||
|
||||
def test_bug006_edge_weight_index_built_once():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
|
||||
assert "edge_weight_index" in src
|
||||
assert "for edge in edge_data:" not in src, "Old O(E*L) loop should be gone"
|
||||
|
||||
|
||||
# ── bug_007: original result id not overwritten ───────────────────────────────
|
||||
|
||||
def test_bug007_original_id_not_overwritten():
|
||||
from semantica.context import agent_context
|
||||
src = inspect.getsource(agent_context.AgentContext._apply_proximity_metadata)
|
||||
assert (
|
||||
'"graph_node_id": result_id' in src
|
||||
or "'graph_node_id': result_id" in src
|
||||
)
|
||||
assert '"id": result_id' not in src, "id should not be overwritten by result_id"
|
||||
|
||||
|
||||
# ── qual_002: no bare except:pass in enrichment blocks ───────────────────────
|
||||
|
||||
def test_qual002_no_bare_except_pass_in_find_path():
|
||||
from semantica.explorer.routes import graph
|
||||
src = inspect.getsource(getattr(graph, "_find_path_impl", graph.find_path))
|
||||
bare_pass = re.findall(r"except Exception:\s*\n\s*pass", src)
|
||||
assert not bare_pass, f"Found bare except:pass: {bare_pass}"
|
||||
assert "logger.debug" in src
|
||||
|
||||
|
||||
# ── TypeScript fixes — checked via raw file reads ─────────────────────────────
|
||||
|
||||
TS_BEHAVIOR = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\behaviors\pathHighlightBehavior.ts"
|
||||
)
|
||||
TS_WORKSPACE = (
|
||||
r"c:\Users\Mohd Kaif\semantica\explorer\src\workspaces"
|
||||
r"\GraphWorkspace\GraphWorkspace.tsx"
|
||||
)
|
||||
|
||||
|
||||
def test_bug008_sweep_generation_counter():
|
||||
with open(TS_BEHAVIOR, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "sweepGeneration" in src, "Generation counter variable must exist"
|
||||
assert "gen !== sweepGeneration" in src, "Stale-callback guard must exist"
|
||||
assert "sweepGeneration++" in src, "Counter must be incremented on cancel"
|
||||
|
||||
|
||||
def test_bug001_semantic_neighborhood_uses_top_k():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert (
|
||||
"top_k=50" in src or 'top_k: "50"' in src
|
||||
), "Should use top_k (not limit) to match backend param"
|
||||
idx = src.find("semantic-neighborhood?")
|
||||
snippet = src[idx: idx + 100]
|
||||
assert "limit=" not in snippet, f"Found 'limit=' in URL snippet: {snippet!r}"
|
||||
|
||||
|
||||
def test_bug002_semantic_neighborhood_response_type_complete():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "anchor_node: string" in src
|
||||
assert "hop_distance?" in src
|
||||
|
||||
|
||||
def test_qual001_ego_heatmap_merged_into_single_effect():
|
||||
with open(TS_WORKSPACE, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
assert "egoModeEnabled, egoMaxHops, heatmapEnabled, selectedNodeId" in src, (
|
||||
"Combined dep array must be present"
|
||||
)
|
||||
# The old separate dep arrays must not exist
|
||||
assert "], [egoModeEnabled, egoMaxHops, selectedNodeId]" not in src
|
||||
assert "], [heatmapEnabled, selectedNodeId]" not in src
|
||||
@@ -0,0 +1,97 @@
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
|
||||
|
||||
def test_get_neighbor_distances_tracks_path_decay_and_band():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node("A", "entity", "Anchor")
|
||||
graph.add_node("B", "entity", "Bridge")
|
||||
graph.add_node("C", "decision", "Decision")
|
||||
graph.add_edge("A", "B", "influences", weight=0.9)
|
||||
graph.add_edge("B", "C", "influences", weight=0.7)
|
||||
|
||||
neighbors = graph.get_neighbor_distances("A", hops=2, min_confidence=0.5)
|
||||
c_neighbor = next(item for item in neighbors if item["id"] == "C")
|
||||
|
||||
assert c_neighbor["hop"] == 2
|
||||
assert c_neighbor["distance_band"] == "near"
|
||||
assert c_neighbor["confidence_decay"] == 0.63
|
||||
assert c_neighbor["path_to_anchor"] == ["A", "B", "C"]
|
||||
|
||||
|
||||
def test_trace_decision_causality_returns_auditable_chain_dicts():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
first = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve initial risk policy",
|
||||
reasoning="Baseline risk controls look sound",
|
||||
outcome="approved",
|
||||
confidence=0.8,
|
||||
entities=["account_123"],
|
||||
)
|
||||
second = graph.record_decision(
|
||||
category="risk",
|
||||
scenario="Approve follow-up risk exception",
|
||||
reasoning="Prior account controls still apply",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["account_123"],
|
||||
)
|
||||
graph._decisions[first]["timestamp"] = 1
|
||||
graph._decisions[second]["timestamp"] = 2
|
||||
|
||||
chains = graph.trace_decision_causality(second, max_depth=2)
|
||||
|
||||
assert chains
|
||||
assert chains[0]["hop_count"] == 1
|
||||
assert chains[0]["distance_band"] == "direct"
|
||||
assert chains[0]["weakest_link"]["from"] == first
|
||||
assert chains[0]["hops"][0]["to"] == second
|
||||
assert "confidence" in chains[0]["interpretation"]
|
||||
assert list(chains[0])[0]["from"] == first
|
||||
|
||||
|
||||
def test_analyze_decision_influence_exposes_score_breakdown():
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
source = graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Approve secured loan",
|
||||
reasoning="Collateral and income verified",
|
||||
outcome="approved",
|
||||
confidence=0.9,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
graph.record_decision(
|
||||
category="loan",
|
||||
scenario="Review related refinance",
|
||||
reasoning="Same borrower and collateral",
|
||||
outcome="review",
|
||||
confidence=0.8,
|
||||
entities=["borrower_1"],
|
||||
)
|
||||
|
||||
result = graph.analyze_decision_influence(source)
|
||||
|
||||
assert result["influence_scores"]
|
||||
score = result["influence_scores"][0]
|
||||
assert set(score["score_breakdown"]) == {
|
||||
"entity_overlap",
|
||||
"category_match",
|
||||
"temporal_proximity",
|
||||
}
|
||||
assert score["is_direct"] is True
|
||||
|
||||
|
||||
def test_cross_graph_path_traverses_link_boundary():
|
||||
left = ContextGraph(advanced_analytics=False)
|
||||
right = ContextGraph(advanced_analytics=False)
|
||||
left.add_node("A", "entity", "Left")
|
||||
right.add_node("B", "entity", "Right")
|
||||
left.link_graph(right, "A", "B")
|
||||
|
||||
path = left.cross_graph_path("A", right, "B")
|
||||
|
||||
assert path["reachable"] is True
|
||||
assert path["hop_count"] == 1
|
||||
assert path["cross_graph_links_used"] == 1
|
||||
assert path["distance_band"] == "direct"
|
||||
assert path["path"] == [(left.graph_id, "A"), (right.graph_id, "B")]
|
||||
@@ -1,13 +1,18 @@
|
||||
import sys
|
||||
import unittest
|
||||
from typing import Dict, Any, List
|
||||
from semantica.deduplication.similarity_calculator import SimilarityCalculator
|
||||
from semantica.deduplication.duplicate_detector import DuplicateDetector
|
||||
from semantica.deduplication.duplicate_detector import (
|
||||
DuplicateCandidate,
|
||||
DuplicateDetector,
|
||||
)
|
||||
from semantica.deduplication.entity_merger import EntityMerger
|
||||
from semantica.deduplication.merge_strategy import MergeStrategy
|
||||
from semantica.deduplication.cluster_builder import ClusterBuilder
|
||||
from semantica.deduplication.registry import MethodRegistry
|
||||
from semantica.deduplication.config import DeduplicationConfig
|
||||
from semantica.deduplication.methods import get_deduplication_method
|
||||
from semantica.utils.progress_tracker import ConsoleProgressDisplay
|
||||
|
||||
class TestDeduplication(unittest.TestCase):
|
||||
|
||||
@@ -203,5 +208,402 @@ class TestDeduplication(unittest.TestCase):
|
||||
self.assertIsNone(invalid)
|
||||
|
||||
|
||||
class TestProgressTrackerEncoding(unittest.TestCase):
|
||||
"""Regression tests for issue #531 — Unicode crash on cp1252 Windows consoles."""
|
||||
|
||||
def _make_cp1252_stdout(self):
|
||||
"""Return a stdout-like object that raises UnicodeEncodeError for non-cp1252 chars."""
|
||||
class CP1252Writer:
|
||||
encoding = "cp1252"
|
||||
def write(self, text):
|
||||
text.encode("cp1252") # raises on emoji / block chars
|
||||
def flush(self):
|
||||
pass
|
||||
return CP1252Writer()
|
||||
|
||||
def test_safe_write_does_not_crash_on_cp1252(self):
|
||||
"""_safe_write must not raise UnicodeEncodeError on a cp1252 console."""
|
||||
display = ConsoleProgressDisplay()
|
||||
orig = sys.stdout
|
||||
sys.stdout = self._make_cp1252_stdout()
|
||||
try:
|
||||
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
|
||||
except UnicodeEncodeError:
|
||||
self.fail("_safe_write raised UnicodeEncodeError on cp1252 stdout")
|
||||
finally:
|
||||
sys.stdout = orig
|
||||
|
||||
def test_update_pipeline_header_does_not_crash_on_cp1252(self):
|
||||
"""update() pipeline header write must not crash on a cp1252 console (issue #531)."""
|
||||
from semantica.utils.progress_tracker import ProgressItem
|
||||
display = ConsoleProgressDisplay()
|
||||
display.use_emoji = True # force emoji path to exercise the fixed branch
|
||||
orig = sys.stdout
|
||||
sys.stdout = self._make_cp1252_stdout()
|
||||
try:
|
||||
display._safe_write("🧠 Semantica - 📊 Current Progress\n")
|
||||
display._safe_write("=" * 150 + "\n")
|
||||
except UnicodeEncodeError:
|
||||
self.fail("Pipeline header write raised UnicodeEncodeError on cp1252 stdout")
|
||||
finally:
|
||||
sys.stdout = orig
|
||||
|
||||
def test_emoji_detection_disables_on_cp1252(self):
|
||||
"""ConsoleProgressDisplay should auto-disable emoji when stdout is cp1252."""
|
||||
orig = sys.stdout
|
||||
sys.stdout = self._make_cp1252_stdout()
|
||||
try:
|
||||
display = ConsoleProgressDisplay()
|
||||
self.assertFalse(display.use_emoji, "use_emoji should be False on cp1252 stdout")
|
||||
finally:
|
||||
sys.stdout = orig
|
||||
|
||||
|
||||
class TestResultLimiting(unittest.TestCase):
|
||||
"""Tests for issue #534 — max_results, top_k_per_entity, min_similarity, sort_by."""
|
||||
|
||||
def setUp(self):
|
||||
# Six entities: three near-duplicate Apple variants + two Microsoft variants + one Google.
|
||||
# Lower thresholds so all intra-brand pairs clear the bar.
|
||||
self.entities = [
|
||||
{"id": "a1", "name": "Apple Inc.", "type": "Company",
|
||||
"properties": {"industry": "Technology"}},
|
||||
{"id": "a2", "name": "Apple", "type": "Company",
|
||||
"properties": {"industry": "Tech"}},
|
||||
{"id": "a3", "name": "Apple Corp", "type": "Company",
|
||||
"properties": {"industry": "Technology"}},
|
||||
{"id": "b1", "name": "Microsoft Corporation", "type": "Company",
|
||||
"properties": {"industry": "Software"}},
|
||||
{"id": "b2", "name": "Microsoft Corp", "type": "Company",
|
||||
"properties": {"industry": "Software"}},
|
||||
{"id": "c1", "name": "Google LLC", "type": "Company",
|
||||
"properties": {"industry": "Internet"}},
|
||||
]
|
||||
self.threshold = 0.3
|
||||
|
||||
def _base_detector(self, **kwargs):
|
||||
return DuplicateDetector(
|
||||
similarity_threshold=self.threshold,
|
||||
confidence_threshold=self.threshold,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# max_results
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_max_results_caps_output(self):
|
||||
detector = self._base_detector(max_results=1)
|
||||
results = detector.detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(results), 1)
|
||||
|
||||
def test_max_results_two(self):
|
||||
detector = self._base_detector(max_results=2)
|
||||
results = detector.detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(results), 2)
|
||||
|
||||
def test_max_results_none_no_cap(self):
|
||||
uncapped = self._base_detector()
|
||||
large_cap = self._base_detector(max_results=999)
|
||||
self.assertEqual(
|
||||
len(uncapped.detect_duplicates(self.entities)),
|
||||
len(large_cap.detect_duplicates(self.entities)),
|
||||
)
|
||||
|
||||
def test_max_results_zero_returns_empty(self):
|
||||
detector = self._base_detector(max_results=0)
|
||||
self.assertEqual(detector.detect_duplicates(self.entities), [])
|
||||
|
||||
def test_max_results_returns_highest_confidence_first(self):
|
||||
"""When capped, the kept candidates must be the highest-confidence ones."""
|
||||
n = 2
|
||||
all_results = self._base_detector().detect_duplicates(self.entities)
|
||||
capped = self._base_detector(max_results=n).detect_duplicates(self.entities)
|
||||
if len(all_results) >= n:
|
||||
expected_ids = {
|
||||
(c.entity1["id"], c.entity2["id"]) for c in all_results[:n]
|
||||
}
|
||||
actual_ids = {
|
||||
(c.entity1["id"], c.entity2["id"]) for c in capped
|
||||
}
|
||||
self.assertEqual(expected_ids, actual_ids)
|
||||
|
||||
def test_max_results_empty_input(self):
|
||||
detector = self._base_detector(max_results=5)
|
||||
self.assertEqual(detector.detect_duplicates([]), [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# top_k_per_entity
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_top_k_per_entity_k1(self):
|
||||
# OR semantics: keep if EITHER entity is under quota.
|
||||
# A popular entity can appear > k times (each new partner brings it back).
|
||||
# Invariant: no (entity1, entity2) pair is returned more than once.
|
||||
k = 1
|
||||
results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
|
||||
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
|
||||
self.assertEqual(len(pairs), len(set(pairs)), "No duplicate pairs should appear")
|
||||
# OR gives >= results than AND — at least 1 result when matches exist
|
||||
and_results = self._base_detector().detect_duplicates(self.entities)
|
||||
if and_results:
|
||||
self.assertGreater(len(results), 0)
|
||||
|
||||
def test_top_k_per_entity_k2(self):
|
||||
# Same OR semantics: no pair appears twice; result is bounded below by k=1 count
|
||||
k = 2
|
||||
results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
|
||||
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
|
||||
self.assertEqual(len(pairs), len(set(pairs)), "No duplicate pairs should appear")
|
||||
k1_results = self._base_detector(top_k_per_entity=1).detect_duplicates(self.entities)
|
||||
self.assertGreaterEqual(len(results), len(k1_results))
|
||||
|
||||
def test_top_k_per_entity_large_k_same_as_none(self):
|
||||
uncapped = self._base_detector().detect_duplicates(self.entities)
|
||||
large_k = self._base_detector(top_k_per_entity=999).detect_duplicates(self.entities)
|
||||
self.assertEqual(len(uncapped), len(large_k))
|
||||
|
||||
def test_top_k_per_entity_empty_input(self):
|
||||
detector = self._base_detector(top_k_per_entity=2)
|
||||
self.assertEqual(detector.detect_duplicates([]), [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# min_similarity
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_min_similarity_all_results_above_floor(self):
|
||||
floor = 0.6
|
||||
results = self._base_detector(min_similarity=floor).detect_duplicates(self.entities)
|
||||
for c in results:
|
||||
self.assertGreaterEqual(
|
||||
c.similarity_score, floor,
|
||||
f"Candidate score {c.similarity_score} is below min_similarity={floor}",
|
||||
)
|
||||
|
||||
def test_min_similarity_very_high_returns_only_exact(self):
|
||||
results = self._base_detector(min_similarity=1.0).detect_duplicates(self.entities)
|
||||
for c in results:
|
||||
self.assertEqual(c.similarity_score, 1.0)
|
||||
|
||||
def test_min_similarity_zero_does_not_over_filter(self):
|
||||
no_floor = self._base_detector().detect_duplicates(self.entities)
|
||||
zero_floor = self._base_detector(min_similarity=0.0).detect_duplicates(self.entities)
|
||||
self.assertEqual(len(no_floor), len(zero_floor))
|
||||
|
||||
def test_min_similarity_stricter_than_threshold_reduces_results(self):
|
||||
"""A min_similarity above similarity_threshold must not increase the result count."""
|
||||
base = self._base_detector().detect_duplicates(self.entities)
|
||||
stricter = self._base_detector(min_similarity=0.8).detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(stricter), len(base))
|
||||
|
||||
def test_min_similarity_empty_input(self):
|
||||
detector = self._base_detector(min_similarity=0.5)
|
||||
self.assertEqual(detector.detect_duplicates([]), [])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# sort_by
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_sort_by_confidence_descending(self):
|
||||
results = self._base_detector(sort_by="confidence").detect_duplicates(self.entities)
|
||||
scores = [c.confidence for c in results]
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
|
||||
def test_sort_by_similarity_score_descending(self):
|
||||
results = self._base_detector(sort_by="similarity_score").detect_duplicates(self.entities)
|
||||
scores = [c.similarity_score for c in results]
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
|
||||
def test_sort_by_default_is_confidence(self):
|
||||
default = self._base_detector().detect_duplicates(self.entities)
|
||||
explicit = self._base_detector(sort_by="confidence").detect_duplicates(self.entities)
|
||||
self.assertEqual(
|
||||
[(c.entity1["id"], c.entity2["id"]) for c in default],
|
||||
[(c.entity1["id"], c.entity2["id"]) for c in explicit],
|
||||
)
|
||||
|
||||
def test_sort_by_invalid_raises_at_construction(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(sort_by="bogus_field")
|
||||
|
||||
def test_sort_by_invalid_message_contains_field_name(self):
|
||||
with self.assertRaises(ValueError, msg="bogus_field") as ctx:
|
||||
self._base_detector(sort_by="bogus_field")
|
||||
self.assertIn("bogus_field", str(ctx.exception))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Input validation (bug_002 / bug_003)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_max_results_negative_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(max_results=-1)
|
||||
|
||||
def test_max_results_float_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(max_results=1.5)
|
||||
|
||||
def test_top_k_per_entity_negative_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(top_k_per_entity=-1)
|
||||
|
||||
def test_top_k_per_entity_float_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(top_k_per_entity=2.5)
|
||||
|
||||
def test_min_similarity_above_1_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(min_similarity=1.1)
|
||||
|
||||
def test_min_similarity_below_0_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._base_detector(min_similarity=-0.1)
|
||||
|
||||
def test_max_results_zero_is_valid(self):
|
||||
# 0 is a non-negative int — must not raise
|
||||
detector = self._base_detector(max_results=0)
|
||||
self.assertEqual(detector.detect_duplicates(self.entities), [])
|
||||
|
||||
def test_top_k_per_entity_zero_is_valid(self):
|
||||
detector = self._base_detector(top_k_per_entity=0)
|
||||
self.assertEqual(detector.detect_duplicates(self.entities), [])
|
||||
|
||||
def test_min_similarity_boundary_0_valid(self):
|
||||
self._base_detector(min_similarity=0.0) # must not raise
|
||||
|
||||
def test_min_similarity_boundary_1_valid(self):
|
||||
self._base_detector(min_similarity=1.0) # must not raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# top_k_per_entity OR semantics (bug_001)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_top_k_or_semantics_keeps_candidate_if_either_under_quota(self):
|
||||
"""A high-ranked candidate must survive even if one of its entities hit k,
|
||||
as long as the other entity is still under quota."""
|
||||
# With k=1 and OR semantics, every entity can appear in AT LEAST one
|
||||
# candidate. Verify that more candidates survive than would under AND.
|
||||
k = 1
|
||||
or_results = self._base_detector(top_k_per_entity=k).detect_duplicates(self.entities)
|
||||
# Each entity should appear at least once — no entity completely starved
|
||||
seen_ids: set = set()
|
||||
for c in or_results:
|
||||
seen_ids.add(c.entity1["id"])
|
||||
seen_ids.add(c.entity2["id"])
|
||||
# Entities that have at least one match above threshold must appear
|
||||
all_ids_in_any_pair: set = set()
|
||||
uncapped = self._base_detector().detect_duplicates(self.entities)
|
||||
for c in uncapped:
|
||||
all_ids_in_any_pair.add(c.entity1["id"])
|
||||
all_ids_in_any_pair.add(c.entity2["id"])
|
||||
self.assertEqual(seen_ids, all_ids_in_any_pair)
|
||||
|
||||
def test_group_merge_updates_normalized_entity_keys_for_int_ids(self):
|
||||
"""Merged groups must update the normalized string lookup keys.
|
||||
|
||||
Regression for stale raw int keys: after a bridge candidate merges two
|
||||
groups, later candidates involving the moved int-ID entities must still
|
||||
attach to the returned group rather than an orphaned removed group.
|
||||
"""
|
||||
entities = [
|
||||
{"id": 1, "name": "Alpha"},
|
||||
{"id": 2, "name": "Alpha duplicate"},
|
||||
{"id": 3, "name": "Alpha bridge"},
|
||||
{"id": 4, "name": "Alpha merged"},
|
||||
{"id": 5, "name": "Alpha later"},
|
||||
]
|
||||
candidates = [
|
||||
DuplicateCandidate(entities[0], entities[1], 0.95, 0.95),
|
||||
DuplicateCandidate(entities[2], entities[3], 0.94, 0.94),
|
||||
DuplicateCandidate(entities[1], entities[2], 0.93, 0.93),
|
||||
DuplicateCandidate(entities[2], entities[4], 0.92, 0.92),
|
||||
]
|
||||
|
||||
groups = self._base_detector()._build_duplicate_groups(candidates)
|
||||
|
||||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(
|
||||
{entity["id"] for entity in groups[0].entities},
|
||||
{1, 2, 3, 4, 5},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Combined options
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_max_results_and_sort_by_similarity(self):
|
||||
n = 2
|
||||
results = self._base_detector(max_results=n, sort_by="similarity_score").detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(results), n)
|
||||
if len(results) == 2:
|
||||
self.assertGreaterEqual(results[0].similarity_score, results[1].similarity_score)
|
||||
|
||||
def test_min_similarity_and_top_k_combined(self):
|
||||
floor, k = 0.5, 1
|
||||
results = self._base_detector(min_similarity=floor, top_k_per_entity=k).detect_duplicates(self.entities)
|
||||
# min_similarity floor still applies
|
||||
for c in results:
|
||||
self.assertGreaterEqual(c.similarity_score, floor)
|
||||
# OR semantics: no pair duplicated
|
||||
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
|
||||
self.assertEqual(len(pairs), len(set(pairs)))
|
||||
|
||||
def test_all_four_options_combined(self):
|
||||
results = self._base_detector(
|
||||
max_results=3,
|
||||
top_k_per_entity=1,
|
||||
min_similarity=0.3,
|
||||
sort_by="similarity_score",
|
||||
).detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(results), 3)
|
||||
scores = [c.similarity_score for c in results]
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
for c in results:
|
||||
self.assertGreaterEqual(c.similarity_score, 0.3)
|
||||
|
||||
def test_max_results_applied_after_top_k(self):
|
||||
"""max_results must slice the already-top-k-filtered list, not pre-empt it."""
|
||||
top_k_only = self._base_detector(top_k_per_entity=1).detect_duplicates(self.entities)
|
||||
both = self._base_detector(top_k_per_entity=1, max_results=1).detect_duplicates(self.entities)
|
||||
self.assertLessEqual(len(both), min(1, len(top_k_only)))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# incremental_detect
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_incremental_detect_max_results(self):
|
||||
new_e, existing = self.entities[:3], self.entities[3:]
|
||||
results = self._base_detector(max_results=1).incremental_detect(new_e, existing)
|
||||
self.assertLessEqual(len(results), 1)
|
||||
|
||||
def test_incremental_detect_min_similarity(self):
|
||||
new_e, existing = self.entities[:3], self.entities[3:]
|
||||
results = self._base_detector(min_similarity=0.99).incremental_detect(new_e, existing)
|
||||
for c in results:
|
||||
self.assertGreaterEqual(c.similarity_score, 0.99)
|
||||
|
||||
def test_incremental_detect_sort_by_similarity(self):
|
||||
new_e, existing = self.entities[:3], self.entities[3:]
|
||||
results = self._base_detector(sort_by="similarity_score").incremental_detect(new_e, existing)
|
||||
scores = [c.similarity_score for c in results]
|
||||
self.assertEqual(scores, sorted(scores, reverse=True))
|
||||
|
||||
def test_incremental_detect_top_k_per_entity(self):
|
||||
new_e, existing = self.entities[:3], self.entities[3:]
|
||||
k = 1
|
||||
results = self._base_detector(top_k_per_entity=k).incremental_detect(new_e, existing)
|
||||
# OR semantics: no pair appears twice
|
||||
pairs = [(c.entity1["id"], c.entity2["id"]) for c in results]
|
||||
self.assertEqual(len(pairs), len(set(pairs)))
|
||||
|
||||
def test_incremental_detect_empty_new_entities(self):
|
||||
detector = self._base_detector(max_results=5)
|
||||
self.assertEqual(detector.incremental_detect([], self.entities), [])
|
||||
|
||||
def test_incremental_detect_empty_existing_entities(self):
|
||||
detector = self._base_detector(max_results=5)
|
||||
self.assertEqual(detector.incremental_detect(self.entities, []), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -733,7 +733,10 @@ def _make_path_session() -> GraphSession:
|
||||
cg = ContextGraph(advanced_analytics=False)
|
||||
cg.add_node("A", node_type="entity", content="Node A")
|
||||
cg.add_node("B", node_type="entity", content="Node B")
|
||||
cg.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34")
|
||||
cg.add_node("disease/term:1", node_type="disease", content="Slash target")
|
||||
cg.add_edge("A", "B", edge_type="connects")
|
||||
cg.add_edge("gene/protein:6164", "disease/term:1", edge_type="connects")
|
||||
|
||||
session = GraphSession(cg)
|
||||
|
||||
@@ -742,6 +745,7 @@ def _make_path_session() -> GraphSession:
|
||||
# PathFinder; this mimics how a KG-backed session would expose the graph.
|
||||
digraph = nx.DiGraph()
|
||||
digraph.add_edge("A", "B")
|
||||
digraph.add_edge("gene/protein:6164", "disease/term:1")
|
||||
session.build_graph_dict = lambda node_ids=None: digraph # type: ignore[method-assign]
|
||||
|
||||
return session
|
||||
@@ -792,6 +796,22 @@ class TestBidirectionalPathRoute:
|
||||
assert body["path"] == ["B", "A"]
|
||||
assert body["directed"] is False
|
||||
|
||||
def test_query_path_route_supports_slash_node_ids(self, path_client):
|
||||
"""Query-param path route must support arbitrary graph ids with slashes."""
|
||||
resp = path_client.get(
|
||||
"/api/graph/path",
|
||||
params={
|
||||
"source": "gene/protein:6164",
|
||||
"target": "disease/term:1",
|
||||
"algorithm": "dijkstra",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["path"] == ["gene/protein:6164", "disease/term:1"]
|
||||
assert body["source"] == "gene/protein:6164"
|
||||
assert body["target"] == "disease/term:1"
|
||||
|
||||
def test_directed_false_forward_path_found(self, path_client):
|
||||
"""directed=false must not break the natural A→B direction."""
|
||||
resp = path_client.get("/api/graph/node/A/path?target=B&directed=false")
|
||||
@@ -865,6 +885,101 @@ class TestBidirectionalPathRoute:
|
||||
from semantica.utils.helpers import classify_path_distance
|
||||
|
||||
|
||||
class _FakeSimilarity:
|
||||
"""Minimal similarity stub shared by slash-safe distance route tests.
|
||||
|
||||
Expects embeddings keyed on 'gene/protein:6164' with query vector [1, 0, 0]
|
||||
and returns a single neighbor result. Tests that need different behaviour
|
||||
can assign a lambda to instance.find_most_similar after construction.
|
||||
"""
|
||||
|
||||
def find_most_similar(self, embeddings, query_embedding, top_k=10):
|
||||
assert "gene/protein:6164" in embeddings
|
||||
assert query_embedding == [1.0, 0.0, 0.0]
|
||||
return [("disease/term:1", 0.74)]
|
||||
|
||||
|
||||
def _make_slash_node_session(*, with_embeddings: bool = True) -> GraphSession:
|
||||
"""Return an isolated GraphSession with slash-containing node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
kwargs = {"embedding": [1.0, 0.0, 0.0]} if with_embeddings else {}
|
||||
graph.add_node("gene/protein:6164", node_type="gene/protein", content="RPL34", **kwargs)
|
||||
graph.add_node(
|
||||
"disease/term:1",
|
||||
node_type="disease",
|
||||
content="Slash target",
|
||||
**({"embedding": [0.7, 0.2, 0.1]} if with_embeddings else {}),
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
session._similarity = _FakeSimilarity()
|
||||
return session
|
||||
|
||||
|
||||
class TestSlashSafeDistanceRoutes:
|
||||
def test_query_semantic_neighborhood_supports_slash_node_ids(self):
|
||||
session = _make_slash_node_session(with_embeddings=True)
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:6164", "top_k": 50},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["anchor_node"] == "gene/protein:6164"
|
||||
assert body["neighbors"][0]["id"] == "disease/term:1"
|
||||
assert body["neighbors"][0]["similarity"] == 0.74
|
||||
|
||||
def test_legacy_semantic_neighborhood_still_works_for_simple_ids(self):
|
||||
"""Legacy path-segment route must still return 200 for slash-free node IDs."""
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
graph.add_node(
|
||||
"semantic_anchor",
|
||||
node_type="entity",
|
||||
content="Semantic anchor",
|
||||
embedding=[1.0, 0.0, 0.0],
|
||||
)
|
||||
graph.add_node(
|
||||
"semantic_neighbor",
|
||||
node_type="entity",
|
||||
content="Semantic neighbor",
|
||||
embedding=[0.8, 0.2, 0.0],
|
||||
)
|
||||
session = GraphSession(graph)
|
||||
fake = _FakeSimilarity()
|
||||
fake.find_most_similar = (
|
||||
lambda embeddings, query_embedding, top_k=10: [("semantic_neighbor", 0.8)]
|
||||
)
|
||||
session._similarity = fake
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/node/semantic_anchor/semantic-neighborhood?top_k=10"
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["anchor_node"] == "semantic_anchor"
|
||||
|
||||
def test_query_semantic_neighborhood_missing_node_returns_404(self, client):
|
||||
resp = client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:missing"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_query_semantic_neighborhood_without_embeddings_returns_503(self):
|
||||
session = _make_slash_node_session(with_embeddings=False)
|
||||
app = create_app(session=session)
|
||||
with TestClient(app) as test_client:
|
||||
resp = test_client.get(
|
||||
"/api/graph/semantic-neighborhood",
|
||||
params={"node_id": "gene/protein:6164", "top_k": 50},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class TestClassifyDistance:
|
||||
"""Unit tests covering all four band boundaries."""
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Tests for Ontology Hub subissue 3 APIs."""
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.context.context_graph import ContextGraph
|
||||
from semantica.explorer.app import create_app
|
||||
from semantica.explorer.session import GraphSession
|
||||
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip(
|
||||
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_ontology_graph() -> ContextGraph:
|
||||
graph = ContextGraph(advanced_analytics=False)
|
||||
onto_a = "http://example.org/onto-a"
|
||||
onto_b = "http://example.org/onto-b"
|
||||
person_a = "http://example.org/onto-a#Person"
|
||||
person_b = "http://example.org/onto-b#PersonRecord"
|
||||
name_a = "http://example.org/onto-a#name"
|
||||
|
||||
graph.add_node(
|
||||
onto_a,
|
||||
node_type="owl:Ontology",
|
||||
content="Ontology A",
|
||||
**{"rdfs:label": "Ontology A", "rdfs:comment": "Primary ontology", "version": "1.0.0"},
|
||||
)
|
||||
graph.add_node(
|
||||
onto_b,
|
||||
node_type="owl:Ontology",
|
||||
content="Ontology B",
|
||||
**{"rdfs:label": "Ontology B", "rdfs:comment": "Partner ontology", "version": "1.0.0"},
|
||||
)
|
||||
graph.add_node(
|
||||
person_a,
|
||||
node_type="owl:Class",
|
||||
content="Person",
|
||||
scheme_uri=onto_a,
|
||||
**{"rdfs:label": "Person", "rdfs:comment": "A person", "skos:definition": "Human actor"},
|
||||
)
|
||||
graph.add_node(
|
||||
name_a,
|
||||
node_type="owl:DatatypeProperty",
|
||||
content="name",
|
||||
scheme_uri=onto_a,
|
||||
**{"rdfs:label": "name", "rdfs:comment": "Display name"},
|
||||
)
|
||||
graph.add_node(
|
||||
person_b,
|
||||
node_type="owl:Class",
|
||||
content="Person Record",
|
||||
scheme_uri=onto_b,
|
||||
**{"rdfs:label": "Person Record", "rdfs:comment": "A person profile"},
|
||||
)
|
||||
graph.add_edge(name_a, person_a, edge_type="rdfs:domain")
|
||||
return graph
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = create_app(session=GraphSession(_build_ontology_graph()))
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_alignment_round_trip(client):
|
||||
payload = {
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://example.org/onto-b#PersonRecord",
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.91,
|
||||
"provenance": "Reviewed from source mapping table",
|
||||
"source": "test",
|
||||
"reviewer": "qa",
|
||||
}
|
||||
created = client.post("/api/ontology/alignments", json=payload)
|
||||
assert created.status_code == 200
|
||||
alignment = created.json()
|
||||
assert alignment["confidence"] == 0.91
|
||||
assert alignment["provenance"] == "Reviewed from source mapping table"
|
||||
|
||||
listed = client.get("/api/ontology/alignments")
|
||||
assert listed.status_code == 200
|
||||
assert [item["id"] for item in listed.json()] == [alignment["id"]]
|
||||
|
||||
removed = client.delete(f"/api/ontology/alignments?id={alignment['id']}")
|
||||
assert removed.status_code == 200
|
||||
assert client.get("/api/ontology/alignments").json() == []
|
||||
|
||||
|
||||
def test_alignment_suggestions_are_ranked(client):
|
||||
response = client.post(
|
||||
"/api/ontology/suggest-alignments",
|
||||
json={
|
||||
"source_ontology_uri": "http://example.org/onto-a",
|
||||
"target_ontology_uri": "http://example.org/onto-b",
|
||||
"threshold": 0.35,
|
||||
"limit": 5,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
suggestions = response.json()
|
||||
assert suggestions
|
||||
# Top suggestion should be the Person→PersonRecord pair (highest label similarity).
|
||||
top = suggestions[0]
|
||||
assert "Person" in top["source_label"]
|
||||
assert "Person" in top["target_label"]
|
||||
# Results must be sorted descending by score.
|
||||
assert suggestions == sorted(suggestions, key=lambda item: item["score"], reverse=True)
|
||||
|
||||
|
||||
def test_health_returns_dimensions_and_issues(client):
|
||||
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total_score"] >= 0
|
||||
assert {dimension["key"] for dimension in payload["dimensions"]} == {
|
||||
"completeness",
|
||||
"consistency",
|
||||
"shacl",
|
||||
"alignment",
|
||||
"documentation",
|
||||
}
|
||||
assert isinstance(payload["issues"], list)
|
||||
|
||||
|
||||
def test_shacl_generate_and_shapes(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/generate",
|
||||
json={"uri": "http://example.org/onto-a", "quality_tier": "strict"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert "sh:NodeShape" in payload["shacl_turtle"]
|
||||
assert payload["shape_count"] >= 1
|
||||
|
||||
shapes = client.get("/api/ontology/shacl/shapes?uri=http%3A%2F%2Fexample.org%2Fonto-a")
|
||||
assert shapes.status_code == 200
|
||||
assert shapes.json()["shapes"]
|
||||
|
||||
|
||||
def test_shacl_validate_returns_unavailable(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/validate",
|
||||
json={
|
||||
"uri": "http://example.org/onto-a",
|
||||
"shacl_turtle": "@prefix sh: <http://www.w3.org/ns/shacl#> .",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "unavailable", "stub must not report conforms=True before validation is wired"
|
||||
assert payload["conforms"] is False
|
||||
assert isinstance(payload["violations"], list)
|
||||
|
||||
|
||||
def test_shacl_validate_rejects_empty_turtle(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/validate",
|
||||
json={"uri": "http://example.org/onto-a", "shacl_turtle": " "},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_health_returns_404_for_unknown_ontology(client):
|
||||
response = client.get("/api/ontology/health?uri=http%3A%2F%2Fnot-loaded.example%2Fonto")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_health_shacl_dimension_is_zero_when_unavailable(client):
|
||||
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
|
||||
shacl_dim = next(d for d in payload["dimensions"] if d["key"] == "shacl")
|
||||
assert shacl_dim["status"] == "unavailable"
|
||||
assert shacl_dim["score"] == 0.0
|
||||
# Total score must NOT include the unavailable dimension in its average.
|
||||
scoreable = [d for d in payload["dimensions"] if d["status"] != "unavailable"]
|
||||
expected_total = round(sum(d["score"] for d in scoreable) / len(scoreable), 1)
|
||||
assert payload["total_score"] == expected_total
|
||||
|
||||
|
||||
def test_delete_unknown_alignment_returns_404(client):
|
||||
response = client.delete("/api/ontology/alignments?id=does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_alignment_upsert_is_idempotent(client):
|
||||
payload = {
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://example.org/onto-b#PersonRecord",
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.80,
|
||||
}
|
||||
first = client.post("/api/ontology/alignments", json=payload).json()
|
||||
updated_payload = {**payload, "confidence": 0.95}
|
||||
second = client.post("/api/ontology/alignments", json=updated_payload).json()
|
||||
assert first["id"] == second["id"], "upsert must reuse the same deterministic ID"
|
||||
assert second["confidence"] == 0.95
|
||||
assert second["created_at"] == first["created_at"], "created_at must not change on update"
|
||||
listed = client.get("/api/ontology/alignments").json()
|
||||
assert len(listed) == 1
|
||||
|
||||
|
||||
def test_alignment_accepts_external_uri(client):
|
||||
payload = {
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://schema.org/Person", # not in local graph
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.75,
|
||||
}
|
||||
response = client.post("/api/ontology/alignments", json=payload)
|
||||
assert response.status_code == 200
|
||||
alignment = response.json()
|
||||
assert alignment["target_label"] == "Person" # derived from URI fragment
|
||||
|
||||
|
||||
def test_suggest_alignments_returns_embedding_similarity(client):
|
||||
response = client.post(
|
||||
"/api/ontology/suggest-alignments",
|
||||
json={
|
||||
"source_ontology_uri": "http://example.org/onto-a",
|
||||
"target_ontology_uri": "http://example.org/onto-b",
|
||||
"threshold": 0.20,
|
||||
"limit": 10,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
suggestions = response.json()
|
||||
assert suggestions
|
||||
# When sklearn is available, embedding_similarity should be populated.
|
||||
top = suggestions[0]
|
||||
assert top["embedding_similarity"] is not None, (
|
||||
"TF-IDF embedding similarity must be returned when sklearn is installed"
|
||||
)
|
||||
# Combined score must be a weighted blend, not purely the label score.
|
||||
assert top["score"] != top["label_similarity"] or top["embedding_similarity"] == top["label_similarity"]
|
||||
|
||||
|
||||
def test_shacl_validate_rejects_invalid_turtle_syntax(client):
|
||||
response = client.post(
|
||||
"/api/ontology/shacl/validate",
|
||||
json={
|
||||
"uri": "http://example.org/onto-a",
|
||||
"shacl_turtle": "this is not valid turtle !!!",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_health_alignment_coverage_uses_set_lookup(client):
|
||||
# Create an alignment first so coverage score can be non-zero.
|
||||
client.post("/api/ontology/alignments", json={
|
||||
"source_uri": "http://example.org/onto-a#Person",
|
||||
"target_uri": "http://example.org/onto-b#PersonRecord",
|
||||
"relation": "owl:equivalentClass",
|
||||
"confidence": 0.9,
|
||||
})
|
||||
payload = client.get("/api/ontology/health?uri=http%3A%2F%2Fexample.org%2Fonto-a").json()
|
||||
alignment_dim = next(d for d in payload["dimensions"] if d["key"] == "alignment")
|
||||
assert alignment_dim["score"] > 0.0, "alignment coverage must be non-zero after recording an alignment"
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _run_python_with_blocked_modules(
|
||||
code: str,
|
||||
blocked_modules: tuple[str, ...],
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
blocker = f"""
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
BLOCKED_MODULES = {blocked_modules!r}
|
||||
|
||||
|
||||
class OptionalDependencyBlocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
root_name = fullname.split(".", 1)[0]
|
||||
if root_name in BLOCKED_MODULES:
|
||||
err = ModuleNotFoundError(f"No module named '{{root_name}}'")
|
||||
err.name = root_name
|
||||
raise err
|
||||
return None
|
||||
|
||||
|
||||
sys.meta_path.insert(0, OptionalDependencyBlocker())
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", textwrap.dedent(blocker + "\n" + code)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_file_ingestion_imports_without_optional_backends() -> None:
|
||||
result = _run_python_with_blocked_modules(
|
||||
"""
|
||||
from semantica.ingest import FileIngestor, ingest_file
|
||||
print(FileIngestor.__name__, callable(ingest_file))
|
||||
""",
|
||||
("git", "bs4"),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "FileIngestor True" in result.stdout
|
||||
|
||||
|
||||
def test_repository_ingestion_reports_missing_gitpython_when_used() -> None:
|
||||
result = _run_python_with_blocked_modules(
|
||||
"""
|
||||
from semantica.ingest import ingest_repository
|
||||
|
||||
try:
|
||||
ingest_repository("https://example.com/repo.git")
|
||||
except Exception as exc:
|
||||
print(type(exc).__name__, exc)
|
||||
else:
|
||||
raise SystemExit("expected repository ingestion to fail without GitPython")
|
||||
""",
|
||||
("git",),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "ConfigurationError" in result.stdout
|
||||
assert "Repository ingestion" in result.stdout
|
||||
assert "GitPython" in result.stdout
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _run_python(code: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", textwrap.dedent(code)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_core_extractors_import_from_package() -> None:
|
||||
from semantica.semantic_extract import (
|
||||
NERExtractor,
|
||||
RelationExtractor,
|
||||
TripletExtractor,
|
||||
)
|
||||
|
||||
assert NERExtractor.__name__ == "NERExtractor"
|
||||
assert RelationExtractor.__name__ == "RelationExtractor"
|
||||
assert TripletExtractor.__name__ == "TripletExtractor"
|
||||
|
||||
|
||||
def test_triple_extractor_alias_imports() -> None:
|
||||
from semantica.semantic_extract import TripleExtractor, TripletExtractor
|
||||
|
||||
assert TripleExtractor is TripletExtractor
|
||||
|
||||
|
||||
def test_method_dispatchers_import_without_extractor_cycle() -> None:
|
||||
from semantica.semantic_extract.methods import (
|
||||
get_entity_method,
|
||||
get_relation_method,
|
||||
get_triplet_method,
|
||||
)
|
||||
|
||||
assert callable(get_entity_method("pattern"))
|
||||
assert callable(get_relation_method("pattern"))
|
||||
assert callable(get_triplet_method("pattern"))
|
||||
|
||||
|
||||
def test_import_order_methods_before_extractors() -> None:
|
||||
result = _run_python(
|
||||
"""
|
||||
from semantica.semantic_extract.methods import get_entity_method
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
|
||||
print(get_entity_method("pattern").__name__, NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__)
|
||||
"""
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "extract_entities_pattern NERExtractor RelationExtractor TripletExtractor" in result.stdout
|
||||
|
||||
|
||||
def test_import_order_extractors_before_methods() -> None:
|
||||
result = _run_python(
|
||||
"""
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
|
||||
from semantica.semantic_extract.methods import get_entity_method
|
||||
print(NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__, get_entity_method("pattern").__name__)
|
||||
"""
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "NERExtractor RelationExtractor TripletExtractor extract_entities_pattern" in result.stdout
|
||||
|
||||
|
||||
def test_legacy_type_imports_still_work() -> None:
|
||||
from semantica.semantic_extract.ner_extractor import Entity
|
||||
from semantica.semantic_extract.relation_extractor import Relation
|
||||
from semantica.semantic_extract.triplet_extractor import Triplet
|
||||
from semantica.semantic_extract.types import (
|
||||
Entity as SharedEntity,
|
||||
Relation as SharedRelation,
|
||||
Triplet as SharedTriplet,
|
||||
)
|
||||
|
||||
assert Entity is SharedEntity
|
||||
assert Relation is SharedRelation
|
||||
assert Triplet is SharedTriplet
|
||||
|
||||
|
||||
def test_core_package_imports_do_not_require_yaml() -> None:
|
||||
result = _run_python(
|
||||
"""
|
||||
import importlib.abc
|
||||
import sys
|
||||
|
||||
|
||||
class OptionalDependencyBlocker(importlib.abc.MetaPathFinder):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname.split(".", 1)[0] == "yaml":
|
||||
raise ModuleNotFoundError("No module named 'yaml'")
|
||||
return None
|
||||
|
||||
|
||||
sys.meta_path.insert(0, OptionalDependencyBlocker())
|
||||
|
||||
from semantica.semantic_extract import NERExtractor, RelationExtractor, TripletExtractor
|
||||
print(NERExtractor.__name__, RelationExtractor.__name__, TripletExtractor.__name__)
|
||||
"""
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "NERExtractor RelationExtractor TripletExtractor" in result.stdout
|
||||
@@ -10,6 +10,10 @@ import importlib.util
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
||||
|
||||
# Save originals before injecting mocks so they can be restored after import.
|
||||
_MOCKED_MODULES = ["spacy", "instructor", "groq", "openai", "sentence_transformers", "transformers"]
|
||||
_original_modules = {k: sys.modules.get(k) for k in _MOCKED_MODULES}
|
||||
|
||||
# Mock external dependencies with __spec__ for importlib checks
|
||||
mock_spacy = MagicMock()
|
||||
mock_spacy.__spec__ = MagicMock()
|
||||
@@ -33,6 +37,16 @@ from semantica.semantic_extract import NERExtractor
|
||||
from semantica.semantic_extract.methods import extract_entities_llm, _extract_entities_chunked, extract_relations_llm, extract_triplets_llm
|
||||
from semantica.semantic_extract.providers import BaseProvider
|
||||
|
||||
# Restore real sys.modules entries now that the mocks have served their purpose
|
||||
# for the imports above. Leaving them in place would poison other test modules
|
||||
# (e.g. test_pr482_deepseek_openai) that need the real packages at test run time.
|
||||
for _key, _original in _original_modules.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_key, None)
|
||||
else:
|
||||
sys.modules[_key] = _original
|
||||
|
||||
|
||||
class EntitiesResponse(BaseModel):
|
||||
entities: List[dict]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user