Spaces:
Running
Running
Merge pull request #85 from biggraph/darabos-nx
Browse files- examples/Airlines demo +0 -0
- examples/NetworkX demo +0 -0
- lynxkite-app/src/lynxkite_app/crdt.py +7 -1
- lynxkite-app/web/src/index.css +19 -0
- lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +1 -1
- lynxkite-app/web/src/workspace/NodeSearch.tsx +6 -1
- lynxkite-app/web/src/workspace/Workspace.tsx +3 -3
- lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +4 -0
- lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +37 -24
- lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +15 -7
- lynxkite-app/web/tests/basic.spec.ts +3 -3
- lynxkite-app/web/tests/errors.spec.ts +11 -11
- lynxkite-app/web/tests/examples.spec.ts +2 -2
- lynxkite-app/web/tests/graph_creation.spec.ts +6 -2
- lynxkite-app/web/tests/lynxkite.ts +4 -1
- lynxkite-core/src/lynxkite/core/executors/one_by_one.py +4 -4
- lynxkite-core/src/lynxkite/core/ops.py +21 -6
- lynxkite-core/src/lynxkite/core/workspace.py +1 -0
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py +57 -37
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py +0 -19
- lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py +236 -24
- lynxkite-graph-analytics/tests/test_lynxkite_ops.py +71 -3
- lynxkite-lynxscribe/README.md +1 -1
- lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py +7 -6
examples/Airlines demo
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
examples/NetworkX demo
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
lynxkite-app/src/lynxkite_app/crdt.py
CHANGED
@@ -54,6 +54,10 @@ class WebsocketServer(pycrdt_websocket.WebsocketServer):
|
|
54 |
# We have two possible sources of truth for the workspaces, the YStore and the JSON files.
|
55 |
# In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
|
56 |
try_to_load_workspace(ws, name)
|
|
|
|
|
|
|
|
|
57 |
room = pycrdt_websocket.YRoom(
|
58 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
59 |
)
|
@@ -197,7 +201,6 @@ async def workspace_changed(name: str, changes: pycrdt.MapEvent, ws_crdt: pycrdt
|
|
197 |
getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
|
198 |
for change in changes
|
199 |
)
|
200 |
-
print(f"Running {name} in {ws_pyd.env}...")
|
201 |
if delay:
|
202 |
task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
|
203 |
delayed_executions[name] = task
|
@@ -221,10 +224,12 @@ async def execute(
|
|
221 |
await asyncio.sleep(delay)
|
222 |
except asyncio.CancelledError:
|
223 |
return
|
|
|
224 |
path = config.DATA_PATH / name
|
225 |
assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
|
226 |
# Save user changes before executing, in case the execution fails.
|
227 |
workspace.save(ws_pyd, path)
|
|
|
228 |
with ws_crdt.doc.transaction():
|
229 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
230 |
if "data" not in nc:
|
@@ -234,6 +239,7 @@ async def execute(
|
|
234 |
np._crdt = nc
|
235 |
await workspace.execute(ws_pyd)
|
236 |
workspace.save(ws_pyd, path)
|
|
|
237 |
|
238 |
|
239 |
@contextlib.asynccontextmanager
|
|
|
54 |
# We have two possible sources of truth for the workspaces, the YStore and the JSON files.
|
55 |
# In case we didn't find the workspace in the YStore, we try to load it from the JSON files.
|
56 |
try_to_load_workspace(ws, name)
|
57 |
+
ws_simple = workspace.Workspace.model_validate(ws.to_py())
|
58 |
+
clean_input(ws_simple)
|
59 |
+
# Set the last known version to the current state, so we don't trigger a change event.
|
60 |
+
last_known_versions[name] = ws_simple
|
61 |
room = pycrdt_websocket.YRoom(
|
62 |
ystore=ystore, ydoc=ydoc, exception_handler=ws_exception_handler
|
63 |
)
|
|
|
201 |
getattr(change, "keys", {}).get("__execution_delay", {}).get("newValue", 0)
|
202 |
for change in changes
|
203 |
)
|
|
|
204 |
if delay:
|
205 |
task = asyncio.create_task(execute(name, ws_crdt, ws_pyd, delay))
|
206 |
delayed_executions[name] = task
|
|
|
224 |
await asyncio.sleep(delay)
|
225 |
except asyncio.CancelledError:
|
226 |
return
|
227 |
+
print(f"Running {name} in {ws_pyd.env}...")
|
228 |
path = config.DATA_PATH / name
|
229 |
assert path.is_relative_to(config.DATA_PATH), "Provided workspace path is invalid"
|
230 |
# Save user changes before executing, in case the execution fails.
|
231 |
workspace.save(ws_pyd, path)
|
232 |
+
ws_pyd._crdt = ws_crdt
|
233 |
with ws_crdt.doc.transaction():
|
234 |
for nc, np in zip(ws_crdt["nodes"], ws_pyd.nodes):
|
235 |
if "data" not in nc:
|
|
|
239 |
np._crdt = nc
|
240 |
await workspace.execute(ws_pyd)
|
241 |
workspace.save(ws_pyd, path)
|
242 |
+
print(f"Finished running {name} in {ws_pyd.env}.")
|
243 |
|
244 |
|
245 |
@contextlib.asynccontextmanager
|
lynxkite-app/web/src/index.css
CHANGED
@@ -235,6 +235,25 @@ body {
|
|
235 |
margin: 10px;
|
236 |
}
|
237 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
238 |
}
|
239 |
|
240 |
.directory {
|
|
|
235 |
margin: 10px;
|
236 |
}
|
237 |
}
|
238 |
+
|
239 |
+
.env-select {
|
240 |
+
background: transparent;
|
241 |
+
color: #39bcf3;
|
242 |
+
}
|
243 |
+
}
|
244 |
+
|
245 |
+
.params-expander {
|
246 |
+
font-size: 15px;
|
247 |
+
padding: 4px;
|
248 |
+
color: #000a;
|
249 |
+
}
|
250 |
+
|
251 |
+
.flippy {
|
252 |
+
transition: transform 0.5s;
|
253 |
+
}
|
254 |
+
|
255 |
+
.flippy.flippy-90 {
|
256 |
+
transform: rotate(-90deg);
|
257 |
}
|
258 |
|
259 |
.directory {
|
lynxkite-app/web/src/workspace/EnvironmentSelector.tsx
CHANGED
@@ -6,7 +6,7 @@ export default function EnvironmentSelector(props: {
|
|
6 |
return (
|
7 |
<>
|
8 |
<select
|
9 |
-
className="select w-full max-w-xs"
|
10 |
name="workspace-env"
|
11 |
value={props.value}
|
12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
|
|
6 |
return (
|
7 |
<>
|
8 |
<select
|
9 |
+
className="env-select select w-full max-w-xs"
|
10 |
name="workspace-env"
|
11 |
value={props.value}
|
12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
lynxkite-app/web/src/workspace/NodeSearch.tsx
CHANGED
@@ -25,9 +25,14 @@ export default function (props: {
|
|
25 |
}),
|
26 |
[props.boxes],
|
27 |
);
|
|
|
|
|
|
|
|
|
|
|
28 |
const hits: { item: OpsOp }[] = searchText
|
29 |
? fuse.search<OpsOp>(searchText)
|
30 |
-
:
|
31 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
32 |
useEffect(() => searchBox.current.focus());
|
33 |
function typed(text: string) {
|
|
|
25 |
}),
|
26 |
[props.boxes],
|
27 |
);
|
28 |
+
const allOps = useMemo(() => {
|
29 |
+
const boxes = Object.values(props.boxes).map((box) => ({ item: box }));
|
30 |
+
boxes.sort((a, b) => a.item.name.localeCompare(b.item.name));
|
31 |
+
return boxes;
|
32 |
+
}, [props.boxes]);
|
33 |
const hits: { item: OpsOp }[] = searchText
|
34 |
? fuse.search<OpsOp>(searchText)
|
35 |
+
: allOps;
|
36 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
37 |
useEffect(() => searchBox.current.focus());
|
38 |
function typed(text: string) {
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
@@ -26,11 +26,11 @@ import { useParams } from "react-router";
|
|
26 |
import useSWR, { type Fetcher } from "swr";
|
27 |
import { WebsocketProvider } from "y-websocket";
|
28 |
// @ts-ignore
|
29 |
-
import ArrowBack from "~icons/tabler/arrow-back.jsx";
|
30 |
-
// @ts-ignore
|
31 |
import Atom from "~icons/tabler/atom.jsx";
|
32 |
// @ts-ignore
|
33 |
import Backspace from "~icons/tabler/backspace.jsx";
|
|
|
|
|
34 |
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
35 |
import favicon from "../assets/favicon.ico";
|
36 |
// import NodeWithTableView from './NodeWithTableView';
|
@@ -303,7 +303,7 @@ function LynxKiteFlow() {
|
|
303 |
<Backspace />
|
304 |
</a>
|
305 |
<a href={`/dir/${parentDir}`}>
|
306 |
-
<
|
307 |
</a>
|
308 |
</div>
|
309 |
</div>
|
|
|
26 |
import useSWR, { type Fetcher } from "swr";
|
27 |
import { WebsocketProvider } from "y-websocket";
|
28 |
// @ts-ignore
|
|
|
|
|
29 |
import Atom from "~icons/tabler/atom.jsx";
|
30 |
// @ts-ignore
|
31 |
import Backspace from "~icons/tabler/backspace.jsx";
|
32 |
+
// @ts-ignore
|
33 |
+
import Close from "~icons/tabler/x.jsx";
|
34 |
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
35 |
import favicon from "../assets/favicon.ico";
|
36 |
// import NodeWithTableView from './NodeWithTableView';
|
|
|
303 |
<Backspace />
|
304 |
</a>
|
305 |
<a href={`/dir/${parentDir}`}>
|
306 |
+
<Close />
|
307 |
</a>
|
308 |
</div>
|
309 |
</div>
|
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx
CHANGED
@@ -73,6 +73,10 @@ export default function NodeParameter({
|
|
73 |
value={value || ""}
|
74 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
75 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
|
|
|
|
|
|
|
|
76 |
/>
|
77 |
</>
|
78 |
)}
|
|
|
73 |
value={value || ""}
|
74 |
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
75 |
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
76 |
+
onKeyDown={(evt) =>
|
77 |
+
evt.code === "Enter" &&
|
78 |
+
onChange(evt.currentTarget.value, { delay: 0 })
|
79 |
+
}
|
80 |
/>
|
81 |
</>
|
82 |
)}
|
lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx
CHANGED
@@ -1,4 +1,7 @@
|
|
1 |
import { useReactFlow } from "@xyflow/react";
|
|
|
|
|
|
|
2 |
import LynxKiteNode from "./LynxKiteNode";
|
3 |
import NodeGroupParameter from "./NodeGroupParameter";
|
4 |
import NodeParameter from "./NodeParameter";
|
@@ -8,6 +11,7 @@ export type UpdateOptions = { delay?: number };
|
|
8 |
function NodeWithParams(props: any) {
|
9 |
const reactFlow = useReactFlow();
|
10 |
const metaParams = props.data.meta?.params;
|
|
|
11 |
|
12 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
13 |
reactFlow.updateNodeData(props.id, (prevData: any) => ({
|
@@ -31,31 +35,40 @@ function NodeWithParams(props: any) {
|
|
31 |
|
32 |
return (
|
33 |
<LynxKiteNode {...props}>
|
34 |
-
{
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
setParam(name, value, opts || {})
|
42 |
-
}
|
43 |
-
deleteParam={(name: string, opts?: UpdateOptions) =>
|
44 |
-
deleteParam(name, opts || {})
|
45 |
-
}
|
46 |
-
/>
|
47 |
-
) : (
|
48 |
-
<NodeParameter
|
49 |
-
name={name}
|
50 |
-
key={name}
|
51 |
-
value={value}
|
52 |
-
meta={metaParams?.[name]}
|
53 |
-
onChange={(value: any, opts?: UpdateOptions) =>
|
54 |
-
setParam(name, value, opts || {})
|
55 |
-
}
|
56 |
-
/>
|
57 |
-
),
|
58 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
{props.children}
|
60 |
</LynxKiteNode>
|
61 |
);
|
|
|
1 |
import { useReactFlow } from "@xyflow/react";
|
2 |
+
import React from "react";
|
3 |
+
// @ts-ignore
|
4 |
+
import Triangle from "~icons/tabler/triangle-inverted-filled.jsx";
|
5 |
import LynxKiteNode from "./LynxKiteNode";
|
6 |
import NodeGroupParameter from "./NodeGroupParameter";
|
7 |
import NodeParameter from "./NodeParameter";
|
|
|
11 |
function NodeWithParams(props: any) {
|
12 |
const reactFlow = useReactFlow();
|
13 |
const metaParams = props.data.meta?.params;
|
14 |
+
const [collapsed, setCollapsed] = React.useState(props.collapsed);
|
15 |
|
16 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
17 |
reactFlow.updateNodeData(props.id, (prevData: any) => ({
|
|
|
35 |
|
36 |
return (
|
37 |
<LynxKiteNode {...props}>
|
38 |
+
{props.collapsed && (
|
39 |
+
<div
|
40 |
+
className="params-expander"
|
41 |
+
onClick={() => setCollapsed(!collapsed)}
|
42 |
+
>
|
43 |
+
<Triangle className={`flippy ${collapsed ? "flippy-90" : ""}`} />
|
44 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
)}
|
46 |
+
{!collapsed &&
|
47 |
+
params.map(([name, value]) =>
|
48 |
+
metaParams?.[name]?.type === "group" ? (
|
49 |
+
<NodeGroupParameter
|
50 |
+
key={name}
|
51 |
+
value={value}
|
52 |
+
meta={metaParams?.[name]}
|
53 |
+
setParam={(name: string, value: any, opts?: UpdateOptions) =>
|
54 |
+
setParam(name, value, opts || {})
|
55 |
+
}
|
56 |
+
deleteParam={(name: string, opts?: UpdateOptions) =>
|
57 |
+
deleteParam(name, opts || {})
|
58 |
+
}
|
59 |
+
/>
|
60 |
+
) : (
|
61 |
+
<NodeParameter
|
62 |
+
name={name}
|
63 |
+
key={name}
|
64 |
+
value={value}
|
65 |
+
meta={metaParams?.[name]}
|
66 |
+
onChange={(value: any, opts?: UpdateOptions) =>
|
67 |
+
setParam(name, value, opts || {})
|
68 |
+
}
|
69 |
+
/>
|
70 |
+
),
|
71 |
+
)}
|
72 |
{props.children}
|
73 |
</LynxKiteNode>
|
74 |
);
|
lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx
CHANGED
@@ -10,20 +10,28 @@ const NodeWithVisualization = (props: any) => {
|
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
12 |
renderer: "canvas",
|
13 |
-
width:
|
14 |
-
height:
|
15 |
});
|
16 |
chartsInstanceRef.current.setOption(opts);
|
17 |
-
const
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
return () => {
|
20 |
-
|
21 |
chartsInstanceRef.current?.dispose();
|
22 |
};
|
23 |
}, [props.data?.display?.value]);
|
|
|
|
|
24 |
return (
|
25 |
-
<NodeWithParams {...props}>
|
26 |
-
<div
|
27 |
</NodeWithParams>
|
28 |
);
|
29 |
};
|
|
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
12 |
renderer: "canvas",
|
13 |
+
width: "auto",
|
14 |
+
height: "auto",
|
15 |
});
|
16 |
chartsInstanceRef.current.setOption(opts);
|
17 |
+
const resizeObserver = new ResizeObserver(() => {
|
18 |
+
const e = chartsRef.current!;
|
19 |
+
e.style.padding = "1px";
|
20 |
+
chartsInstanceRef.current?.resize();
|
21 |
+
e.style.padding = "0";
|
22 |
+
});
|
23 |
+
const observed = chartsRef.current;
|
24 |
+
resizeObserver.observe(observed);
|
25 |
return () => {
|
26 |
+
resizeObserver.unobserve(observed);
|
27 |
chartsInstanceRef.current?.dispose();
|
28 |
};
|
29 |
}, [props.data?.display?.value]);
|
30 |
+
const nodeStyle = { display: "flex", flexDirection: "column" };
|
31 |
+
const vizStyle = { flex: 1 };
|
32 |
return (
|
33 |
+
<NodeWithParams nodeStyle={nodeStyle} collapsed {...props}>
|
34 |
+
<div style={vizStyle} ref={chartsRef} />
|
35 |
</NodeWithParams>
|
36 |
);
|
37 |
};
|
lynxkite-app/web/tests/basic.spec.ts
CHANGED
@@ -35,9 +35,9 @@ test("Box creation & deletion per env", async () => {
|
|
35 |
});
|
36 |
|
37 |
test("Delete multi-handle boxes", async () => {
|
38 |
-
await workspace.addBox("
|
39 |
-
await workspace.deleteBoxes(["
|
40 |
-
await expect(workspace.getBox("
|
41 |
});
|
42 |
|
43 |
test("Drag box", async () => {
|
|
|
35 |
});
|
36 |
|
37 |
test("Delete multi-handle boxes", async () => {
|
38 |
+
await workspace.addBox("NX › PageRank");
|
39 |
+
await workspace.deleteBoxes(["NX › PageRank 1"]);
|
40 |
+
await expect(workspace.getBox("NX › PageRank 1")).not.toBeVisible();
|
41 |
});
|
42 |
|
43 |
test("Drag box", async () => {
|
lynxkite-app/web/tests/errors.spec.ts
CHANGED
@@ -20,24 +20,24 @@ test.afterEach(async () => {
|
|
20 |
test("missing parameter", async () => {
|
21 |
// Test the correct error message is displayed when a required parameter is missing,
|
22 |
// and that the error message is removed when the parameter is filled.
|
23 |
-
await workspace.addBox("
|
24 |
-
const graphBox = workspace.getBox("
|
25 |
-
await graphBox.locator("
|
26 |
-
|
27 |
-
"invalid literal for int() with base 10: ''",
|
28 |
-
);
|
29 |
-
await graphBox.locator("input").fill("10");
|
30 |
await expect(graphBox.locator(".error")).not.toBeVisible();
|
31 |
});
|
32 |
|
33 |
test("unknown operation", async () => {
|
34 |
// Test that the correct error is displayed when the operation does not belong to
|
35 |
// the current environment.
|
36 |
-
await workspace.addBox("
|
|
|
|
|
37 |
await workspace.setEnv("LynxScribe");
|
38 |
-
const csvBox = workspace.getBox("
|
39 |
-
|
40 |
-
|
|
|
41 |
await workspace.setEnv("LynxKite Graph Analytics");
|
42 |
await expect(csvBox.locator(".error")).not.toBeVisible();
|
43 |
});
|
|
|
20 |
test("missing parameter", async () => {
|
21 |
// Test the correct error message is displayed when a required parameter is missing,
|
22 |
// and that the error message is removed when the parameter is filled.
|
23 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
24 |
+
const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
|
25 |
+
await expect(graphBox.locator(".error")).toHaveText("n is unset.");
|
26 |
+
await graphBox.getByLabel("n", { exact: true }).fill("10");
|
|
|
|
|
|
|
27 |
await expect(graphBox.locator(".error")).not.toBeVisible();
|
28 |
});
|
29 |
|
30 |
test("unknown operation", async () => {
|
31 |
// Test that the correct error is displayed when the operation does not belong to
|
32 |
// the current environment.
|
33 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
34 |
+
const graphBox = workspace.getBox("NX › Scale-Free Graph 1");
|
35 |
+
await graphBox.getByLabel("n", { exact: true }).fill("10");
|
36 |
await workspace.setEnv("LynxScribe");
|
37 |
+
const csvBox = workspace.getBox("NX › Scale-Free Graph 1");
|
38 |
+
await expect(csvBox.locator(".error")).toHaveText(
|
39 |
+
'Operation "NX › Scale-Free Graph" not found.',
|
40 |
+
);
|
41 |
await workspace.setEnv("LynxKite Graph Analytics");
|
42 |
await expect(csvBox.locator(".error")).not.toBeVisible();
|
43 |
});
|
lynxkite-app/web/tests/examples.spec.ts
CHANGED
@@ -23,13 +23,13 @@ test.fail("AIMO example", async ({ page }) => {
|
|
23 |
await ws.expectErrorFree();
|
24 |
});
|
25 |
|
26 |
-
test
|
27 |
// Fails because of missing OPENAI_API_KEY
|
28 |
const ws = await Workspace.open(page, "LynxScribe demo");
|
29 |
await ws.expectErrorFree();
|
30 |
});
|
31 |
|
32 |
-
test
|
33 |
// Fails due to some issue with ChromaDB
|
34 |
const ws = await Workspace.open(page, "Graph RAG");
|
35 |
await ws.expectErrorFree(process.env.CI ? 2000 : 500);
|
|
|
23 |
await ws.expectErrorFree();
|
24 |
});
|
25 |
|
26 |
+
test("LynxScribe example", async ({ page }) => {
|
27 |
// Fails because of missing OPENAI_API_KEY
|
28 |
const ws = await Workspace.open(page, "LynxScribe demo");
|
29 |
await ws.expectErrorFree();
|
30 |
});
|
31 |
|
32 |
+
test("Graph RAG", async ({ page }) => {
|
33 |
// Fails due to some issue with ChromaDB
|
34 |
const ws = await Workspace.open(page, "Graph RAG");
|
35 |
await ws.expectErrorFree(process.env.CI ? 2000 : 500);
|
lynxkite-app/web/tests/graph_creation.spec.ts
CHANGED
@@ -9,9 +9,13 @@ test.beforeEach(async ({ browser }) => {
|
|
9 |
await browser.newPage(),
|
10 |
"graph_creation_spec_test",
|
11 |
);
|
12 |
-
await workspace.addBox("
|
|
|
|
|
|
|
|
|
13 |
await workspace.addBox("Create graph");
|
14 |
-
await workspace.connectBoxes("
|
15 |
});
|
16 |
|
17 |
test.afterEach(async () => {
|
|
|
9 |
await browser.newPage(),
|
10 |
"graph_creation_spec_test",
|
11 |
);
|
12 |
+
await workspace.addBox("NX › Scale-Free Graph");
|
13 |
+
await workspace
|
14 |
+
.getBox("NX › Scale-Free Graph 1")
|
15 |
+
.getByLabel("n", { exact: true })
|
16 |
+
.fill("10");
|
17 |
await workspace.addBox("Create graph");
|
18 |
+
await workspace.connectBoxes("NX › Scale-Free Graph 1", "Create graph 1");
|
19 |
});
|
20 |
|
21 |
test.afterEach(async () => {
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
@@ -65,7 +65,10 @@ export class Workspace {
|
|
65 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
66 |
await this.page.locator(".ws-name").click();
|
67 |
await this.page.keyboard.press("/");
|
68 |
-
await this.page
|
|
|
|
|
|
|
69 |
await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
|
70 |
}
|
71 |
|
|
|
65 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
66 |
await this.page.locator(".ws-name").click();
|
67 |
await this.page.keyboard.press("/");
|
68 |
+
await this.page
|
69 |
+
.locator(".node-search")
|
70 |
+
.getByText(boxName, { exact: true })
|
71 |
+
.click();
|
72 |
await expect(this.getBoxes()).toHaveCount(allBoxes.length + 1);
|
73 |
}
|
74 |
|
lynxkite-core/src/lynxkite/core/executors/one_by_one.py
CHANGED
@@ -142,12 +142,12 @@ async def execute(ws: workspace.Workspace, catalog, cache=None):
|
|
142 |
key = make_cache_key((inputs, params))
|
143 |
if key not in cache:
|
144 |
result: ops.Result = op(*inputs, **params)
|
145 |
-
output = await await_if_needed(result.output)
|
146 |
-
cache[key] =
|
147 |
-
|
148 |
else:
|
149 |
result = op(*inputs, **params)
|
150 |
-
|
151 |
except Exception as e:
|
152 |
traceback.print_exc()
|
153 |
node.publish_error(e)
|
|
|
142 |
key = make_cache_key((inputs, params))
|
143 |
if key not in cache:
|
144 |
result: ops.Result = op(*inputs, **params)
|
145 |
+
result.output = await await_if_needed(result.output)
|
146 |
+
cache[key] = result
|
147 |
+
result = cache[key]
|
148 |
else:
|
149 |
result = op(*inputs, **params)
|
150 |
+
output = await await_if_needed(result.output)
|
151 |
except Exception as e:
|
152 |
traceback.print_exc()
|
153 |
node.publish_error(e)
|
lynxkite-core/src/lynxkite/core/ops.py
CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
4 |
import enum
|
5 |
import functools
|
6 |
import inspect
|
|
|
7 |
import pydantic
|
8 |
import typing
|
9 |
from dataclasses import dataclass
|
@@ -123,6 +124,25 @@ def basic_outputs(*names):
|
|
123 |
return {name: Output(name=name, type=None) for name in names}
|
124 |
|
125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
126 |
class Op(BaseConfig):
|
127 |
func: typing.Callable = pydantic.Field(exclude=True)
|
128 |
name: str
|
@@ -136,12 +156,7 @@ class Op(BaseConfig):
|
|
136 |
# Convert parameters.
|
137 |
for p in params:
|
138 |
if p in self.params:
|
139 |
-
|
140 |
-
params[p] = int(params[p])
|
141 |
-
elif self.params[p].type is float:
|
142 |
-
params[p] = float(params[p])
|
143 |
-
elif isinstance(self.params[p].type, enum.EnumMeta):
|
144 |
-
params[p] = self.params[p].type[params[p]]
|
145 |
res = self.func(*inputs, **params)
|
146 |
if not isinstance(res, Result):
|
147 |
# Automatically wrap the result in a Result object, if it isn't already.
|
|
|
4 |
import enum
|
5 |
import functools
|
6 |
import inspect
|
7 |
+
import types
|
8 |
import pydantic
|
9 |
import typing
|
10 |
from dataclasses import dataclass
|
|
|
124 |
return {name: Output(name=name, type=None) for name in names}
|
125 |
|
126 |
|
127 |
+
def _param_to_type(name, value, type):
|
128 |
+
value = value or ""
|
129 |
+
if type is int:
|
130 |
+
assert value != "", f"{name} is unset."
|
131 |
+
return int(value)
|
132 |
+
if type is float:
|
133 |
+
assert value != "", f"{name} is unset."
|
134 |
+
return float(value)
|
135 |
+
if isinstance(type, enum.EnumMeta):
|
136 |
+
return type[value]
|
137 |
+
if isinstance(type, types.UnionType):
|
138 |
+
match type.__args__:
|
139 |
+
case (types.NoneType, type):
|
140 |
+
return None if value == "" else _param_to_type(name, value, type)
|
141 |
+
case (type, types.NoneType):
|
142 |
+
return None if value == "" else _param_to_type(name, value, type)
|
143 |
+
return value
|
144 |
+
|
145 |
+
|
146 |
class Op(BaseConfig):
|
147 |
func: typing.Callable = pydantic.Field(exclude=True)
|
148 |
name: str
|
|
|
156 |
# Convert parameters.
|
157 |
for p in params:
|
158 |
if p in self.params:
|
159 |
+
params[p] = _param_to_type(p, params[p], self.params[p].type)
|
|
|
|
|
|
|
|
|
|
|
160 |
res = self.func(*inputs, **params)
|
161 |
if not isinstance(res, Result):
|
162 |
# Automatically wrap the result in a Result object, if it isn't already.
|
lynxkite-core/src/lynxkite/core/workspace.py
CHANGED
@@ -92,6 +92,7 @@ class Workspace(BaseConfig):
|
|
92 |
env: str = ""
|
93 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
94 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
|
|
95 |
|
96 |
|
97 |
async def execute(ws: Workspace):
|
|
|
92 |
env: str = ""
|
93 |
nodes: list[WorkspaceNode] = dataclasses.field(default_factory=list)
|
94 |
edges: list[WorkspaceEdge] = dataclasses.field(default_factory=list)
|
95 |
+
_crdt: pycrdt.Map
|
96 |
|
97 |
|
98 |
async def execute(ws: Workspace):
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/core.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
"""Graph analytics executor and data types."""
|
2 |
|
3 |
-
|
|
|
4 |
import dataclasses
|
5 |
import functools
|
6 |
import networkx as nx
|
@@ -134,54 +135,73 @@ def nx_node_attribute_func(name):
|
|
134 |
return decorator
|
135 |
|
136 |
|
137 |
-
def disambiguate_edges(ws):
|
138 |
"""If an input plug is connected to multiple edges, keep only the last edge."""
|
139 |
seen = set()
|
140 |
for edge in reversed(ws.edges):
|
141 |
if (edge.target, edge.targetHandle) in seen:
|
142 |
-
ws.edges.
|
|
|
|
|
|
|
143 |
seen.add((edge.target, edge.targetHandle))
|
144 |
|
145 |
|
146 |
@ops.register_executor(ENV)
|
147 |
-
async def execute(ws):
|
148 |
catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
|
149 |
disambiguate_edges(ws)
|
150 |
outputs = {}
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
|
|
159 |
# All inputs for this node are ready, we can compute the output.
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
|
186 |
|
187 |
def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
|
|
|
1 |
"""Graph analytics executor and data types."""
|
2 |
|
3 |
+
import os
|
4 |
+
from lynxkite.core import ops, workspace
|
5 |
import dataclasses
|
6 |
import functools
|
7 |
import networkx as nx
|
|
|
135 |
return decorator
|
136 |
|
137 |
|
138 |
+
def disambiguate_edges(ws: workspace.Workspace):
|
139 |
"""If an input plug is connected to multiple edges, keep only the last edge."""
|
140 |
seen = set()
|
141 |
for edge in reversed(ws.edges):
|
142 |
if (edge.target, edge.targetHandle) in seen:
|
143 |
+
i = ws.edges.index(edge)
|
144 |
+
del ws.edges[i]
|
145 |
+
if hasattr(ws, "_crdt"):
|
146 |
+
del ws._crdt["edges"][i]
|
147 |
seen.add((edge.target, edge.targetHandle))
|
148 |
|
149 |
|
150 |
@ops.register_executor(ENV)
|
151 |
+
async def execute(ws: workspace.Workspace):
|
152 |
catalog: dict[str, ops.Op] = ops.CATALOGS[ws.env]
|
153 |
disambiguate_edges(ws)
|
154 |
outputs = {}
|
155 |
+
nodes = {node.id: node for node in ws.nodes}
|
156 |
+
todo = set(nodes.keys())
|
157 |
+
progress = True
|
158 |
+
while progress:
|
159 |
+
progress = False
|
160 |
+
for id in list(todo):
|
161 |
+
node = nodes[id]
|
162 |
+
input_nodes = [edge.source for edge in ws.edges if edge.target == id]
|
163 |
+
if all(input in outputs for input in input_nodes):
|
164 |
# All inputs for this node are ready, we can compute the output.
|
165 |
+
todo.remove(id)
|
166 |
+
progress = True
|
167 |
+
_execute_node(node, ws, catalog, outputs)
|
168 |
+
|
169 |
+
|
170 |
+
def _execute_node(node, ws, catalog, outputs):
|
171 |
+
params = {**node.data.params}
|
172 |
+
op = catalog.get(node.data.title)
|
173 |
+
if not op:
|
174 |
+
node.publish_error("Operation not found in catalog")
|
175 |
+
return
|
176 |
+
node.publish_started()
|
177 |
+
input_map = {
|
178 |
+
edge.targetHandle: outputs[edge.source]
|
179 |
+
for edge in ws.edges
|
180 |
+
if edge.target == node.id
|
181 |
+
}
|
182 |
+
try:
|
183 |
+
# Convert inputs types to match operation signature.
|
184 |
+
inputs = []
|
185 |
+
for p in op.inputs.values():
|
186 |
+
if p.name not in input_map:
|
187 |
+
node.publish_error(f"Missing input: {p.name}")
|
188 |
+
return
|
189 |
+
x = input_map[p.name]
|
190 |
+
if p.type == nx.Graph and isinstance(x, Bundle):
|
191 |
+
x = x.to_nx()
|
192 |
+
elif p.type == Bundle and isinstance(x, nx.Graph):
|
193 |
+
x = Bundle.from_nx(x)
|
194 |
+
elif p.type == Bundle and isinstance(x, pd.DataFrame):
|
195 |
+
x = Bundle.from_df(x)
|
196 |
+
inputs.append(x)
|
197 |
+
result = op(*inputs, **params)
|
198 |
+
except Exception as e:
|
199 |
+
if os.environ.get("LYNXKITE_LOG_OP_ERRORS"):
|
200 |
+
traceback.print_exc()
|
201 |
+
node.publish_error(e)
|
202 |
+
return
|
203 |
+
outputs[node.id] = result.output
|
204 |
+
node.publish_result(result)
|
205 |
|
206 |
|
207 |
def df_for_frontend(df: pd.DataFrame, limit: int) -> pd.DataFrame:
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/lynxkite_ops.py
CHANGED
@@ -122,25 +122,6 @@ def import_osm(*, location: str):
|
|
122 |
return ox.graph.graph_from_place(location, network_type="drive")
|
123 |
|
124 |
|
125 |
-
@op("Create scale-free graph")
|
126 |
-
def create_scale_free_graph(*, nodes: int = 10):
|
127 |
-
"""Creates a scale-free graph with the given number of nodes."""
|
128 |
-
return nx.scale_free_graph(nodes)
|
129 |
-
|
130 |
-
|
131 |
-
@op("Compute PageRank")
|
132 |
-
@core.nx_node_attribute_func("pagerank")
|
133 |
-
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
134 |
-
# TODO: This requires scipy to be installed.
|
135 |
-
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
136 |
-
|
137 |
-
|
138 |
-
@op("Compute betweenness centrality")
|
139 |
-
@core.nx_node_attribute_func("betweenness_centrality")
|
140 |
-
def compute_betweenness_centrality(graph: nx.Graph, *, k=10):
|
141 |
-
return nx.betweenness_centrality(graph, k=k)
|
142 |
-
|
143 |
-
|
144 |
@op("Discard loop edges")
|
145 |
def discard_loop_edges(graph: nx.Graph):
|
146 |
graph = graph.copy()
|
|
|
122 |
return ox.graph.graph_from_place(location, network_type="drive")
|
123 |
|
124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
125 |
@op("Discard loop edges")
|
126 |
def discard_loop_edges(graph: nx.Graph):
|
127 |
graph = graph.copy()
|
lynxkite-graph-analytics/src/lynxkite_graph_analytics/networkx_ops.py
CHANGED
@@ -1,13 +1,155 @@
|
|
1 |
"""Automatically wraps all NetworkX functions as LynxKite operations."""
|
2 |
|
|
|
|
|
3 |
from lynxkite.core import ops
|
4 |
import functools
|
5 |
import inspect
|
6 |
import networkx as nx
|
|
|
|
|
|
|
7 |
|
8 |
ENV = "LynxKite Graph Analytics"
|
9 |
|
10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
def wrapped(name: str, func):
|
12 |
@functools.wraps(func)
|
13 |
def wrapper(*args, **kwargs):
|
@@ -15,48 +157,118 @@ def wrapped(name: str, func):
|
|
15 |
if v == "None":
|
16 |
kwargs[k] = None
|
17 |
res = func(*args, **kwargs)
|
|
|
18 |
if isinstance(res, nx.Graph):
|
19 |
return res
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
return wrapper
|
26 |
|
27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
def register_networkx(env: str):
|
29 |
cat = ops.CATALOGS.setdefault(env, {})
|
|
|
30 |
for name, func in nx.__dict__.items():
|
31 |
if hasattr(func, "graphs"):
|
32 |
-
|
|
|
|
|
|
|
33 |
inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
str(param.default)
|
38 |
-
if type(param.default) in [str, int, float]
|
39 |
-
else None,
|
40 |
-
param.annotation,
|
41 |
-
)
|
42 |
-
for name, param in sig.parameters.items()
|
43 |
-
if name not in ["G", "backend", "backend_kwargs", "create_using"]
|
44 |
-
}
|
45 |
-
for p in params.values():
|
46 |
-
if not p.type:
|
47 |
-
# Guess the type based on the name.
|
48 |
-
if len(p.name) == 1:
|
49 |
-
p.type = int
|
50 |
-
name = "NX › " + name.replace("_", " ").title()
|
51 |
op = ops.Op(
|
52 |
func=wrapped(name, func),
|
53 |
-
name=
|
54 |
params=params,
|
55 |
inputs=inputs,
|
56 |
outputs={"output": ops.Output(name="output", type=nx.Graph)},
|
57 |
type="basic",
|
58 |
)
|
59 |
-
cat[
|
|
|
|
|
60 |
|
61 |
|
62 |
register_networkx(ENV)
|
|
|
1 |
"""Automatically wraps all NetworkX functions as LynxKite operations."""
|
2 |
|
3 |
+
import collections
|
4 |
+
import types
|
5 |
from lynxkite.core import ops
|
6 |
import functools
|
7 |
import inspect
|
8 |
import networkx as nx
|
9 |
+
import re
|
10 |
+
|
11 |
+
import pandas as pd
|
12 |
|
13 |
ENV = "LynxKite Graph Analytics"
|
14 |
|
15 |
|
16 |
+
class UnsupportedParameterType(Exception):
|
17 |
+
pass
|
18 |
+
|
19 |
+
|
20 |
+
_UNSUPPORTED = object()
|
21 |
+
_SKIP = object()
|
22 |
+
|
23 |
+
|
24 |
+
def doc_to_type(name: str, type_hint: str) -> type:
|
25 |
+
type_hint = type_hint.lower()
|
26 |
+
type_hint = re.sub("[(][^)]+[)]", "", type_hint).strip().strip(".")
|
27 |
+
if " " in name or "http" in name:
|
28 |
+
return _UNSUPPORTED # Not a parameter type.
|
29 |
+
if type_hint.endswith(", optional"):
|
30 |
+
w = doc_to_type(name, type_hint.removesuffix(", optional").strip())
|
31 |
+
if w is _UNSUPPORTED:
|
32 |
+
return _SKIP
|
33 |
+
return w if w is _SKIP else w | None
|
34 |
+
if type_hint in [
|
35 |
+
"a digraph or multidigraph",
|
36 |
+
"a graph g",
|
37 |
+
"graph",
|
38 |
+
"graphs",
|
39 |
+
"networkx graph instance",
|
40 |
+
"networkx graph",
|
41 |
+
"networkx undirected graph",
|
42 |
+
"nx.graph",
|
43 |
+
"undirected graph",
|
44 |
+
"undirected networkx graph",
|
45 |
+
] or type_hint.startswith("networkx graph"):
|
46 |
+
return nx.Graph
|
47 |
+
elif type_hint in [
|
48 |
+
"digraph-like",
|
49 |
+
"digraph",
|
50 |
+
"directed graph",
|
51 |
+
"networkx digraph",
|
52 |
+
"networkx directed graph",
|
53 |
+
"nx.digraph",
|
54 |
+
]:
|
55 |
+
return nx.DiGraph
|
56 |
+
elif type_hint == "node":
|
57 |
+
return _UNSUPPORTED
|
58 |
+
elif type_hint == '"node (optional)"':
|
59 |
+
return _SKIP
|
60 |
+
elif type_hint == '"edge"':
|
61 |
+
return _UNSUPPORTED
|
62 |
+
elif type_hint == '"edge (optional)"':
|
63 |
+
return _SKIP
|
64 |
+
elif type_hint in ["class", "data type"]:
|
65 |
+
return _UNSUPPORTED
|
66 |
+
elif type_hint in ["string", "str", "node label"]:
|
67 |
+
return str
|
68 |
+
elif type_hint in ["string or none", "none or string", "string, or none"]:
|
69 |
+
return str | None
|
70 |
+
elif type_hint in ["int", "integer"]:
|
71 |
+
return int
|
72 |
+
elif type_hint in ["bool", "boolean"]:
|
73 |
+
return bool
|
74 |
+
elif type_hint == "tuple":
|
75 |
+
return _UNSUPPORTED
|
76 |
+
elif type_hint == "set":
|
77 |
+
return _UNSUPPORTED
|
78 |
+
elif type_hint == "list of floats":
|
79 |
+
return _UNSUPPORTED
|
80 |
+
elif type_hint == "list of floats or float":
|
81 |
+
return float
|
82 |
+
elif type_hint in ["dict", "dictionary"]:
|
83 |
+
return _UNSUPPORTED
|
84 |
+
elif type_hint == "scalar or dictionary":
|
85 |
+
return float
|
86 |
+
elif type_hint == "none or dict":
|
87 |
+
return _SKIP
|
88 |
+
elif type_hint in ["function", "callable"]:
|
89 |
+
return _UNSUPPORTED
|
90 |
+
elif type_hint in [
|
91 |
+
"collection",
|
92 |
+
"container of nodes",
|
93 |
+
"list of nodes",
|
94 |
+
]:
|
95 |
+
return _UNSUPPORTED
|
96 |
+
elif type_hint in [
|
97 |
+
"container",
|
98 |
+
"generator",
|
99 |
+
"iterable",
|
100 |
+
"iterator",
|
101 |
+
"list or iterable container",
|
102 |
+
"list or iterable",
|
103 |
+
"list or set",
|
104 |
+
"list or tuple",
|
105 |
+
"list",
|
106 |
+
]:
|
107 |
+
return _UNSUPPORTED
|
108 |
+
elif type_hint == "generator of sets":
|
109 |
+
return _UNSUPPORTED
|
110 |
+
elif type_hint == "dict or a set of 2 or 3 tuples":
|
111 |
+
return _UNSUPPORTED
|
112 |
+
elif type_hint == "set of 2 or 3 tuples":
|
113 |
+
return _UNSUPPORTED
|
114 |
+
elif type_hint == "none, string or function":
|
115 |
+
return str | None
|
116 |
+
elif type_hint == "string or function" and name == "weight":
|
117 |
+
return str
|
118 |
+
elif type_hint == "integer, float, or none":
|
119 |
+
return float | None
|
120 |
+
elif type_hint in [
|
121 |
+
"float",
|
122 |
+
"int or float",
|
123 |
+
"integer or float",
|
124 |
+
"integer, float",
|
125 |
+
"number",
|
126 |
+
"numeric",
|
127 |
+
"real",
|
128 |
+
"scalar",
|
129 |
+
]:
|
130 |
+
return float
|
131 |
+
elif type_hint in ["integer or none", "int or none"]:
|
132 |
+
return int | None
|
133 |
+
elif name == "seed":
|
134 |
+
return int | None
|
135 |
+
elif name == "weight":
|
136 |
+
return str
|
137 |
+
elif type_hint == "object":
|
138 |
+
return _UNSUPPORTED
|
139 |
+
return _SKIP
|
140 |
+
|
141 |
+
|
142 |
+
def types_from_doc(doc: str) -> dict[str, type]:
|
143 |
+
types = {}
|
144 |
+
for line in doc.splitlines():
|
145 |
+
if ":" in line:
|
146 |
+
a, b = line.split(":", 1)
|
147 |
+
for a in a.split(","):
|
148 |
+
a = a.strip()
|
149 |
+
types[a] = doc_to_type(a, b)
|
150 |
+
return types
|
151 |
+
|
152 |
+
|
153 |
def wrapped(name: str, func):
|
154 |
@functools.wraps(func)
|
155 |
def wrapper(*args, **kwargs):
|
|
|
157 |
if v == "None":
|
158 |
kwargs[k] = None
|
159 |
res = func(*args, **kwargs)
|
160 |
+
# Figure out what the returned value is.
|
161 |
if isinstance(res, nx.Graph):
|
162 |
return res
|
163 |
+
if isinstance(res, types.GeneratorType):
|
164 |
+
res = list(res)
|
165 |
+
if name in ["articulation_points"]:
|
166 |
+
graph = args[0].copy()
|
167 |
+
nx.set_node_attributes(graph, 0, name=name)
|
168 |
+
nx.set_node_attributes(graph, {r: 1 for r in res}, name=name)
|
169 |
+
return graph
|
170 |
+
if isinstance(res, collections.abc.Sized):
|
171 |
+
if len(res) == 0:
|
172 |
+
return pd.DataFrame()
|
173 |
+
for a in args:
|
174 |
+
if isinstance(a, nx.Graph):
|
175 |
+
if a.number_of_nodes() == len(res):
|
176 |
+
graph = a.copy()
|
177 |
+
nx.set_node_attributes(graph, values=res, name=name)
|
178 |
+
return graph
|
179 |
+
if a.number_of_edges() == len(res):
|
180 |
+
graph = a.copy()
|
181 |
+
nx.set_edge_attributes(graph, values=res, name=name)
|
182 |
+
return graph
|
183 |
+
return pd.DataFrame({name: res})
|
184 |
+
return pd.DataFrame({name: [res]})
|
185 |
|
186 |
return wrapper
|
187 |
|
188 |
|
189 |
+
def _get_params(func) -> dict | None:
|
190 |
+
sig = inspect.signature(func)
|
191 |
+
# Get types from docstring.
|
192 |
+
types = types_from_doc(func.__doc__)
|
193 |
+
# Always hide these.
|
194 |
+
for k in ["backend", "backend_kwargs", "create_using"]:
|
195 |
+
types[k] = _SKIP
|
196 |
+
# Add in types based on signature.
|
197 |
+
for k, param in sig.parameters.items():
|
198 |
+
if k in types:
|
199 |
+
continue
|
200 |
+
if param.annotation is not param.empty:
|
201 |
+
types[k] = param.annotation
|
202 |
+
if k in ["i", "j", "n"]:
|
203 |
+
types[k] = int
|
204 |
+
params = {}
|
205 |
+
for name, param in sig.parameters.items():
|
206 |
+
_type = types.get(name, _UNSUPPORTED)
|
207 |
+
if _type is _UNSUPPORTED:
|
208 |
+
raise UnsupportedParameterType(name)
|
209 |
+
if _type is _SKIP or _type in [nx.Graph, nx.DiGraph]:
|
210 |
+
continue
|
211 |
+
params[name] = ops.Parameter.basic(
|
212 |
+
name=name,
|
213 |
+
default=str(param.default)
|
214 |
+
if type(param.default) in [str, int, float]
|
215 |
+
else None,
|
216 |
+
type=_type,
|
217 |
+
)
|
218 |
+
return params
|
219 |
+
|
220 |
+
|
221 |
+
_REPLACEMENTS = [
|
222 |
+
("Barabasi Albert", "Barabasi–Albert"),
|
223 |
+
("Bellman Ford", "Bellman–Ford"),
|
224 |
+
("Bethe Hessian", "Bethe–Hessian"),
|
225 |
+
("Bfs", "BFS"),
|
226 |
+
("Dag ", "DAG "),
|
227 |
+
("Dfs", "DFS"),
|
228 |
+
("Dorogovtsev Goltsev Mendes", "Dorogovtsev–Goltsev–Mendes"),
|
229 |
+
("Erdos Renyi", "Erdos–Renyi"),
|
230 |
+
("Floyd Warshall", "Floyd–Warshall"),
|
231 |
+
("Gnc", "G(n,c)"),
|
232 |
+
("Gnm", "G(n,m)"),
|
233 |
+
("Gnp", "G(n,p)"),
|
234 |
+
("Gnr", "G(n,r)"),
|
235 |
+
("Havel Hakimi", "Havel–Hakimi"),
|
236 |
+
("Hkn", "H(k,n)"),
|
237 |
+
("Hnm", "H(n,m)"),
|
238 |
+
("Kl ", "KL "),
|
239 |
+
("Moebius Kantor", "Moebius–Kantor"),
|
240 |
+
("Pagerank", "PageRank"),
|
241 |
+
("Scale Free", "Scale-Free"),
|
242 |
+
("Vf2Pp", "VF2++"),
|
243 |
+
("Watts Strogatz", "Watts–Strogatz"),
|
244 |
+
("Weisfeiler Lehman", "Weisfeiler–Lehman"),
|
245 |
+
]
|
246 |
+
|
247 |
+
|
248 |
def register_networkx(env: str):
|
249 |
cat = ops.CATALOGS.setdefault(env, {})
|
250 |
+
counter = 0
|
251 |
for name, func in nx.__dict__.items():
|
252 |
if hasattr(func, "graphs"):
|
253 |
+
try:
|
254 |
+
params = _get_params(func)
|
255 |
+
except UnsupportedParameterType:
|
256 |
+
continue
|
257 |
inputs = {k: ops.Input(name=k, type=nx.Graph) for k in func.graphs}
|
258 |
+
nicename = "NX › " + name.replace("_", " ").title()
|
259 |
+
for a, b in _REPLACEMENTS:
|
260 |
+
nicename = nicename.replace(a, b)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
261 |
op = ops.Op(
|
262 |
func=wrapped(name, func),
|
263 |
+
name=nicename,
|
264 |
params=params,
|
265 |
inputs=inputs,
|
266 |
outputs={"output": ops.Output(name="output", type=nx.Graph)},
|
267 |
type="basic",
|
268 |
)
|
269 |
+
cat[nicename] = op
|
270 |
+
counter += 1
|
271 |
+
print(f"Registered {counter} NetworkX operations.")
|
272 |
|
273 |
|
274 |
register_networkx(ENV)
|
lynxkite-graph-analytics/tests/test_lynxkite_ops.py
CHANGED
@@ -77,13 +77,13 @@ async def test_execute_operation_inputs_correct_cast():
|
|
77 |
)
|
78 |
ws.edges = [
|
79 |
workspace.WorkspaceEdge(
|
80 |
-
id="1", source="1", target="2", sourceHandle="
|
81 |
),
|
82 |
workspace.WorkspaceEdge(
|
83 |
-
id="2", source="2", target="3", sourceHandle="
|
84 |
),
|
85 |
workspace.WorkspaceEdge(
|
86 |
-
id="3", source="3", target="4", sourceHandle="
|
87 |
),
|
88 |
]
|
89 |
|
@@ -92,5 +92,73 @@ async def test_execute_operation_inputs_correct_cast():
|
|
92 |
assert all([node.data.error is None for node in ws.nodes])
|
93 |
|
94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
95 |
if __name__ == "__main__":
|
96 |
pytest.main()
|
|
|
77 |
)
|
78 |
ws.edges = [
|
79 |
workspace.WorkspaceEdge(
|
80 |
+
id="1", source="1", target="2", sourceHandle="output", targetHandle="graph"
|
81 |
),
|
82 |
workspace.WorkspaceEdge(
|
83 |
+
id="2", source="2", target="3", sourceHandle="output", targetHandle="bundle"
|
84 |
),
|
85 |
workspace.WorkspaceEdge(
|
86 |
+
id="3", source="3", target="4", sourceHandle="output", targetHandle="bundle"
|
87 |
),
|
88 |
]
|
89 |
|
|
|
92 |
assert all([node.data.error is None for node in ws.nodes])
|
93 |
|
94 |
|
95 |
+
async def test_multiple_inputs():
|
96 |
+
"""Make sure each input goes to the right argument."""
|
97 |
+
op = ops.op_registration("test")
|
98 |
+
|
99 |
+
@op("One")
|
100 |
+
def one():
|
101 |
+
return 1
|
102 |
+
|
103 |
+
@op("Two")
|
104 |
+
def two():
|
105 |
+
return 2
|
106 |
+
|
107 |
+
@op("Smaller?", view="visualization")
|
108 |
+
def is_smaller(a, b):
|
109 |
+
return a < b
|
110 |
+
|
111 |
+
ws = workspace.Workspace(env="test")
|
112 |
+
ws.nodes.append(
|
113 |
+
workspace.WorkspaceNode(
|
114 |
+
id="one",
|
115 |
+
type="cool",
|
116 |
+
data=workspace.WorkspaceNodeData(title="One", params={}),
|
117 |
+
position=workspace.Position(x=0, y=0),
|
118 |
+
)
|
119 |
+
)
|
120 |
+
ws.nodes.append(
|
121 |
+
workspace.WorkspaceNode(
|
122 |
+
id="two",
|
123 |
+
type="cool",
|
124 |
+
data=workspace.WorkspaceNodeData(title="Two", params={}),
|
125 |
+
position=workspace.Position(x=100, y=0),
|
126 |
+
)
|
127 |
+
)
|
128 |
+
ws.nodes.append(
|
129 |
+
workspace.WorkspaceNode(
|
130 |
+
id="smaller",
|
131 |
+
type="cool",
|
132 |
+
data=workspace.WorkspaceNodeData(title="Smaller?", params={}),
|
133 |
+
position=workspace.Position(x=200, y=0),
|
134 |
+
)
|
135 |
+
)
|
136 |
+
ws.edges = [
|
137 |
+
workspace.WorkspaceEdge(
|
138 |
+
id="one",
|
139 |
+
source="one",
|
140 |
+
target="smaller",
|
141 |
+
sourceHandle="output",
|
142 |
+
targetHandle="a",
|
143 |
+
),
|
144 |
+
workspace.WorkspaceEdge(
|
145 |
+
id="two",
|
146 |
+
source="two",
|
147 |
+
target="smaller",
|
148 |
+
sourceHandle="output",
|
149 |
+
targetHandle="b",
|
150 |
+
),
|
151 |
+
]
|
152 |
+
|
153 |
+
await execute(ws)
|
154 |
+
|
155 |
+
assert ws.nodes[-1].data.display is True
|
156 |
+
# Flip the inputs.
|
157 |
+
ws.edges[0].targetHandle = "b"
|
158 |
+
ws.edges[1].targetHandle = "a"
|
159 |
+
await execute(ws)
|
160 |
+
assert ws.nodes[-1].data.display is False
|
161 |
+
|
162 |
+
|
163 |
if __name__ == "__main__":
|
164 |
pytest.main()
|
lynxkite-lynxscribe/README.md
CHANGED
@@ -5,7 +5,7 @@ LynxKite UI for building LynxScribe chat applications. Also runs the chat applic
|
|
5 |
To run a chat UI for LynxScribe workspaces:
|
6 |
|
7 |
```bash
|
8 |
-
WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/
|
9 |
```
|
10 |
|
11 |
Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
|
|
|
5 |
To run a chat UI for LynxScribe workspaces:
|
6 |
|
7 |
```bash
|
8 |
+
WEBUI_AUTH=false OPENAI_API_BASE_URL=http://localhost:8000/api/service/lynxkite_lynxscribe uvx open-webui serve
|
9 |
```
|
10 |
|
11 |
Or use [Lynx WebUI](https://github.com/biggraph/lynx-webui/) instead of Open WebUI.
|
lynxkite-lynxscribe/src/lynxkite_lynxscribe/lynxscribe_ops.py
CHANGED
@@ -2,6 +2,8 @@
|
|
2 |
LynxScribe configuration and testing in LynxKite.
|
3 |
"""
|
4 |
|
|
|
|
|
5 |
from lynxscribe.core.llm.base import get_llm_engine
|
6 |
from lynxscribe.core.vector_store.base import get_vector_store
|
7 |
from lynxscribe.common.config import load_config
|
@@ -221,10 +223,8 @@ def view(input):
|
|
221 |
|
222 |
|
223 |
async def get_chat_api(ws):
|
224 |
-
import pathlib
|
225 |
from lynxkite.core import workspace
|
226 |
|
227 |
-
DATA_PATH = pathlib.Path.cwd() / "data"
|
228 |
path = DATA_PATH / ws
|
229 |
assert path.is_relative_to(DATA_PATH)
|
230 |
assert path.exists(), f"Workspace {path} does not exist"
|
@@ -285,18 +285,19 @@ async def api_service_get(request):
|
|
285 |
return {"error": "Not found"}
|
286 |
|
287 |
|
|
|
|
|
|
|
288 |
def get_lynxscribe_workspaces():
|
289 |
-
import pathlib
|
290 |
from lynxkite.core import workspace
|
291 |
|
292 |
-
DATA_DIR = pathlib.Path.cwd() / "data"
|
293 |
workspaces = []
|
294 |
-
for p in
|
295 |
if p.is_file():
|
296 |
try:
|
297 |
ws = workspace.load(p)
|
298 |
if ws.env == ENV:
|
299 |
-
workspaces.append(p.relative_to(
|
300 |
except Exception:
|
301 |
pass # Ignore files that are not valid workspaces.
|
302 |
workspaces.sort()
|
|
|
2 |
LynxScribe configuration and testing in LynxKite.
|
3 |
"""
|
4 |
|
5 |
+
import os
|
6 |
+
import pathlib
|
7 |
from lynxscribe.core.llm.base import get_llm_engine
|
8 |
from lynxscribe.core.vector_store.base import get_vector_store
|
9 |
from lynxscribe.common.config import load_config
|
|
|
223 |
|
224 |
|
225 |
async def get_chat_api(ws):
|
|
|
226 |
from lynxkite.core import workspace
|
227 |
|
|
|
228 |
path = DATA_PATH / ws
|
229 |
assert path.is_relative_to(DATA_PATH)
|
230 |
assert path.exists(), f"Workspace {path} does not exist"
|
|
|
285 |
return {"error": "Not found"}
|
286 |
|
287 |
|
288 |
+
DATA_PATH = pathlib.Path(os.environ.get("LYNXKITE_DATA", "lynxkite_data"))
|
289 |
+
|
290 |
+
|
291 |
def get_lynxscribe_workspaces():
|
|
|
292 |
from lynxkite.core import workspace
|
293 |
|
|
|
294 |
workspaces = []
|
295 |
+
for p in DATA_PATH.glob("**/*"):
|
296 |
if p.is_file():
|
297 |
try:
|
298 |
ws = workspace.load(p)
|
299 |
if ws.env == ENV:
|
300 |
+
workspaces.append(p.relative_to(DATA_PATH))
|
301 |
except Exception:
|
302 |
pass # Ignore files that are not valid workspaces.
|
303 |
workspaces.sort()
|