Spaces:
Running
Running
Start writing "LLM logic" executor.
Browse files- server/llm_ops.py +128 -35
- server/lynxkite_ops.py +127 -45
- server/ops.py +8 -73
- server/test_llm_ops.py +28 -0
- server/workspace.py +2 -0
- web/src/Directory.svelte +7 -1
- web/src/EnvironmentSelector.svelte +14 -0
- web/src/LynxKiteFlow.svelte +0 -10
- web/src/NodeWithTableView.svelte +4 -4
- web/src/NodeWithVisualization.svelte +2 -2
server/llm_ops.py
CHANGED
@@ -1,13 +1,29 @@
|
|
1 |
'''For specifying an LLM agent logic flow.'''
|
2 |
from . import ops
|
|
|
|
|
3 |
import json
|
4 |
import openai
|
5 |
import pandas as pd
|
|
|
|
|
6 |
|
7 |
client = openai.OpenAI(base_url="http://localhost:11434/v1")
|
8 |
CACHE = {}
|
|
|
|
|
9 |
|
10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
11 |
|
12 |
def chat(*args, **kwargs):
|
13 |
key = json.dumps({'args': args, 'kwargs': kwargs})
|
@@ -23,52 +39,129 @@ def input(*, filename: ops.PathStr, key: str):
|
|
23 |
@op("Create prompt")
|
24 |
def create_prompt(input, *, template: ops.LongStr):
|
25 |
assert template, 'Please specify the template. Refer to columns using their names in uppercase.'
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
for c in df.columns:
|
31 |
-
p = p.replace(c.upper(), str(row[c]))
|
32 |
-
prompts.append(p)
|
33 |
-
df['prompt'] = prompts
|
34 |
-
return df
|
35 |
-
|
36 |
|
37 |
@op("Ask LLM")
|
38 |
def ask_llm(input, *, model: str, accepted_regex: str = None, max_tokens: int = 100):
|
39 |
assert model, 'Please specify the model.'
|
40 |
-
assert 'prompt' in input
|
41 |
-
|
42 |
-
g = {}
|
43 |
if accepted_regex:
|
44 |
-
|
45 |
"guided_regex": accepted_regex,
|
46 |
}
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
df.loc[i, 'response'] = res
|
57 |
-
return df
|
58 |
|
59 |
@op("View", view="table_view")
|
60 |
-
def view(input):
|
61 |
-
v =
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
return v
|
68 |
|
69 |
@ops.input_position(input="right")
|
70 |
@ops.output_position(output="left")
|
71 |
@op("Loop")
|
72 |
-
def loop(input, *, max_iterations: int =
|
73 |
-
'''Data can flow back here
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
74 |
return input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
'''For specifying an LLM agent logic flow.'''
|
2 |
from . import ops
|
3 |
+
import dataclasses
|
4 |
+
import inspect
|
5 |
import json
|
6 |
import openai
|
7 |
import pandas as pd
|
8 |
+
import traceback
|
9 |
+
from . import workspace
|
10 |
|
11 |
client = openai.OpenAI(base_url="http://localhost:11434/v1")
|
12 |
CACHE = {}
|
13 |
+
ENV = 'LLM logic'
|
14 |
+
op = ops.op_registration(ENV)
|
15 |
|
16 |
+
@dataclasses.dataclass
|
17 |
+
class Context:
|
18 |
+
'''Passed to operation functions as "_ctx" if they have such a parameter.'''
|
19 |
+
node: workspace.WorkspaceNode
|
20 |
+
last_result = None
|
21 |
+
|
22 |
+
@dataclasses.dataclass
|
23 |
+
class Output:
|
24 |
+
'''Return this to send values to specific outputs of a node.'''
|
25 |
+
output_handle: str
|
26 |
+
value: dict
|
27 |
|
28 |
def chat(*args, **kwargs):
|
29 |
key = json.dumps({'args': args, 'kwargs': kwargs})
|
|
|
39 |
@op("Create prompt")
|
40 |
def create_prompt(input, *, template: ops.LongStr):
|
41 |
assert template, 'Please specify the template. Refer to columns using their names in uppercase.'
|
42 |
+
p = template
|
43 |
+
for k, v in input.items():
|
44 |
+
p = p.replace(k.upper(), str(v))
|
45 |
+
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
|
47 |
@op("Ask LLM")
|
48 |
def ask_llm(input, *, model: str, accepted_regex: str = None, max_tokens: int = 100):
|
49 |
assert model, 'Please specify the model.'
|
50 |
+
assert 'prompt' in input, 'Please create the prompt first.'
|
51 |
+
options = {}
|
|
|
52 |
if accepted_regex:
|
53 |
+
options['extra_body'] = {
|
54 |
"guided_regex": accepted_regex,
|
55 |
}
|
56 |
+
results = chat(
|
57 |
+
model=model,
|
58 |
+
max_tokens=max_tokens,
|
59 |
+
messages=[
|
60 |
+
{"role": "user", "content": input['prompt']},
|
61 |
+
],
|
62 |
+
**options,
|
63 |
+
)
|
64 |
+
return [{**input, 'response': r} for r in results]
|
|
|
|
|
65 |
|
66 |
@op("View", view="table_view")
|
67 |
+
def view(input, *, _ctx: Context):
|
68 |
+
v = _ctx.last_result
|
69 |
+
if v:
|
70 |
+
columns = v['dataframes']['df']['columns']
|
71 |
+
v['dataframes']['df']['data'].append([input[c] for c in columns])
|
72 |
+
else:
|
73 |
+
columns = [str(c) for c in input.keys() if not str(c).startswith('_')]
|
74 |
+
v = {
|
75 |
+
'dataframes': { 'df': {
|
76 |
+
'columns': columns,
|
77 |
+
'data': [input[c] for c in columns],
|
78 |
+
}}
|
79 |
+
}
|
80 |
return v
|
81 |
|
82 |
@ops.input_position(input="right")
|
83 |
@ops.output_position(output="left")
|
84 |
@op("Loop")
|
85 |
+
def loop(input, *, max_iterations: int = 3, _ctx: Context):
|
86 |
+
'''Data can flow back here max_iterations-1 times.'''
|
87 |
+
key = f'iterations-{_ctx.node.id}'
|
88 |
+
input[key] = input.get(key, 0) + 1
|
89 |
+
if input[key] < max_iterations:
|
90 |
+
return input
|
91 |
+
|
92 |
+
@op('Branch', outputs=['true', 'false'])
|
93 |
+
def branch(input, *, expression: str):
|
94 |
+
res = eval(expression, input)
|
95 |
+
return Output(str(bool(res)).lower(), input)
|
96 |
+
|
97 |
+
@ops.input_position(db="top")
|
98 |
+
@op('RAG')
|
99 |
+
def rag(input, db, *, closest_n: int=10):
|
100 |
return input
|
101 |
+
|
102 |
+
@op('Run Python')
|
103 |
+
def run_python(input, *, template: str):
|
104 |
+
assert template, 'Please specify the template. Refer to columns using their names in uppercase.'
|
105 |
+
p = template
|
106 |
+
for k, v in input.items():
|
107 |
+
p = p.replace(k.upper(), str(v))
|
108 |
+
return p
|
109 |
+
|
110 |
+
|
111 |
+
|
112 |
+
@ops.register_executor(ENV)
|
113 |
+
def execute(ws):
|
114 |
+
catalog = ops.CATALOGS[ENV]
|
115 |
+
nodes = {n.id: n for n in ws.nodes}
|
116 |
+
contexts = {n.id: Context(n) for n in ws.nodes}
|
117 |
+
edges = {n.id: [] for n in ws.nodes}
|
118 |
+
for e in ws.edges:
|
119 |
+
edges[e.source].append(e.target)
|
120 |
+
tasks = {}
|
121 |
+
NO_INPUT = object() # Marker for initial tasks.
|
122 |
+
for node in ws.nodes:
|
123 |
+
node.data.error = None
|
124 |
+
op = catalog[node.data.title]
|
125 |
+
# Start tasks for nodes that have no inputs.
|
126 |
+
if not op.inputs:
|
127 |
+
tasks[node.id] = [NO_INPUT]
|
128 |
+
# Run the rest until we run out of tasks.
|
129 |
+
while tasks:
|
130 |
+
n, ts = tasks.popitem()
|
131 |
+
node = nodes[n]
|
132 |
+
data = node.data
|
133 |
+
op = catalog[data.title]
|
134 |
+
params = {**data.params}
|
135 |
+
if has_ctx(op):
|
136 |
+
params['_ctx'] = contexts[node.id]
|
137 |
+
results = []
|
138 |
+
for task in ts:
|
139 |
+
try:
|
140 |
+
if task is NO_INPUT:
|
141 |
+
result = op(**params)
|
142 |
+
else:
|
143 |
+
# TODO: Tasks with multiple inputs?
|
144 |
+
result = op(task, **params)
|
145 |
+
except Exception as e:
|
146 |
+
traceback.print_exc()
|
147 |
+
data.error = str(e)
|
148 |
+
break
|
149 |
+
contexts[node.id].last_result = result
|
150 |
+
# Returned lists and DataFrames are considered multiple tasks.
|
151 |
+
if isinstance(result, pd.DataFrame):
|
152 |
+
result = df_to_list(result)
|
153 |
+
elif not isinstance(result, list):
|
154 |
+
result = [result]
|
155 |
+
results.extend(result)
|
156 |
+
else: # Finished all tasks without errors.
|
157 |
+
if op.type == 'visualization' or op.type == 'table_view':
|
158 |
+
data.display = results
|
159 |
+
for target in edges[node.id]:
|
160 |
+
tasks.setdefault(target, []).extend(results)
|
161 |
+
|
162 |
+
def df_to_list(df):
|
163 |
+
return [dict(zip(df.columns, row)) for row in df.values]
|
164 |
+
|
165 |
+
def has_ctx(op):
|
166 |
+
sig = inspect.signature(op.func)
|
167 |
+
return '_ctx' in sig.parameters
|
server/lynxkite_ops.py
CHANGED
@@ -1,12 +1,135 @@
|
|
1 |
'''Some operations. To be split into separate files when we have more.'''
|
2 |
from . import ops
|
|
|
|
|
3 |
import matplotlib
|
4 |
import networkx as nx
|
5 |
import pandas as pd
|
6 |
import traceback
|
|
|
7 |
|
8 |
op = ops.op_registration('LynxKite')
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
@op("Import Parquet")
|
11 |
def import_parquet(*, filename: str):
|
12 |
'''Imports a parquet file.'''
|
@@ -18,7 +141,7 @@ def create_scale_free_graph(*, nodes: int = 10):
|
|
18 |
return nx.scale_free_graph(nodes)
|
19 |
|
20 |
@op("Compute PageRank")
|
21 |
-
@
|
22 |
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
23 |
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
24 |
|
@@ -26,7 +149,7 @@ def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
|
26 |
@op("Sample graph")
|
27 |
def sample_graph(graph: nx.Graph, *, nodes: int = 100):
|
28 |
'''Takes a subgraph.'''
|
29 |
-
return nx.scale_free_graph(nodes)
|
30 |
|
31 |
|
32 |
def _map_color(value):
|
@@ -36,7 +159,7 @@ def _map_color(value):
|
|
36 |
return ['#{:02x}{:02x}{:02x}'.format(int(r*255), int(g*255), int(b*255)) for r, g, b in rgba[:, :3]]
|
37 |
|
38 |
@op("Visualize graph", view="visualization")
|
39 |
-
def visualize_graph(graph:
|
40 |
nodes = graph.dfs['nodes'].copy()
|
41 |
if color_nodes_by:
|
42 |
nodes['color'] = _map_color(nodes[color_nodes_by])
|
@@ -79,7 +202,7 @@ def visualize_graph(graph: ops.Bundle, *, color_nodes_by: 'node_attribute' = Non
|
|
79 |
return v
|
80 |
|
81 |
@op("View tables", view="table_view")
|
82 |
-
def view_tables(bundle:
|
83 |
v = {
|
84 |
'dataframes': { name: {
|
85 |
'columns': [str(c) for c in df.columns],
|
@@ -89,44 +212,3 @@ def view_tables(bundle: ops.Bundle):
|
|
89 |
'other': bundle.other,
|
90 |
}
|
91 |
return v
|
92 |
-
|
93 |
-
@ops.register_executor('LynxKite')
|
94 |
-
def execute(ws):
|
95 |
-
catalog = ops.CATALOGS['LynxKite']
|
96 |
-
# Nodes are responsible for interpreting/executing their child nodes.
|
97 |
-
nodes = [n for n in ws.nodes if not n.parentId]
|
98 |
-
children = {}
|
99 |
-
for n in ws.nodes:
|
100 |
-
if n.parentId:
|
101 |
-
children.setdefault(n.parentId, []).append(n)
|
102 |
-
outputs = {}
|
103 |
-
failed = 0
|
104 |
-
while len(outputs) + failed < len(nodes):
|
105 |
-
for node in nodes:
|
106 |
-
if node.id in outputs:
|
107 |
-
continue
|
108 |
-
inputs = [edge.source for edge in ws.edges if edge.target == node.id]
|
109 |
-
if all(input in outputs for input in inputs):
|
110 |
-
inputs = [outputs[input] for input in inputs]
|
111 |
-
data = node.data
|
112 |
-
op = catalog[data.title]
|
113 |
-
params = {**data.params}
|
114 |
-
if op.sub_nodes:
|
115 |
-
sub_nodes = children.get(node.id, [])
|
116 |
-
sub_node_ids = [node.id for node in sub_nodes]
|
117 |
-
sub_edges = [edge for edge in ws.edges if edge.source in sub_node_ids]
|
118 |
-
params['sub_flow'] = {'nodes': sub_nodes, 'edges': sub_edges}
|
119 |
-
try:
|
120 |
-
output = op(*inputs, **params)
|
121 |
-
except Exception as e:
|
122 |
-
traceback.print_exc()
|
123 |
-
data.error = str(e)
|
124 |
-
failed += 1
|
125 |
-
continue
|
126 |
-
if len(op.inputs) == 1 and op.inputs.get('multi') == '*':
|
127 |
-
# It's a flexible input. Create n+1 handles.
|
128 |
-
data.inputs = {f'input{i}': None for i in range(len(inputs) + 1)}
|
129 |
-
data.error = None
|
130 |
-
outputs[node.id] = output
|
131 |
-
if op.type == 'visualization' or op.type == 'table_view':
|
132 |
-
data.view = output
|
|
|
1 |
'''Some operations. To be split into separate files when we have more.'''
|
2 |
from . import ops
|
3 |
+
import dataclasses
|
4 |
+
import functools
|
5 |
import matplotlib
|
6 |
import networkx as nx
|
7 |
import pandas as pd
|
8 |
import traceback
|
9 |
+
import typing
|
10 |
|
11 |
op = ops.op_registration('LynxKite')
|
12 |
|
13 |
+
@dataclasses.dataclass
|
14 |
+
class RelationDefinition:
|
15 |
+
'''Defines a set of edges.'''
|
16 |
+
df: str # The DataFrame that contains the edges.
|
17 |
+
source_column: str # The column in the edge DataFrame that contains the source node ID.
|
18 |
+
target_column: str # The column in the edge DataFrame that contains the target node ID.
|
19 |
+
source_table: str # The DataFrame that contains the source nodes.
|
20 |
+
target_table: str # The DataFrame that contains the target nodes.
|
21 |
+
source_key: str # The column in the source table that contains the node ID.
|
22 |
+
target_key: str # The column in the target table that contains the node ID.
|
23 |
+
|
24 |
+
@dataclasses.dataclass
|
25 |
+
class Bundle:
|
26 |
+
'''A collection of DataFrames and other data.
|
27 |
+
|
28 |
+
Can efficiently represent a knowledge graph (homogeneous or heterogeneous) or tabular data.
|
29 |
+
It can also carry other data, such as a trained model.
|
30 |
+
'''
|
31 |
+
dfs: dict[str, pd.DataFrame] = dataclasses.field(default_factory=dict)
|
32 |
+
relations: list[RelationDefinition] = dataclasses.field(default_factory=list)
|
33 |
+
other: dict[str, typing.Any] = None
|
34 |
+
|
35 |
+
@classmethod
|
36 |
+
def from_nx(cls, graph: nx.Graph):
|
37 |
+
edges = nx.to_pandas_edgelist(graph)
|
38 |
+
d = dict(graph.nodes(data=True))
|
39 |
+
nodes = pd.DataFrame(d.values(), index=d.keys())
|
40 |
+
nodes['id'] = nodes.index
|
41 |
+
return cls(
|
42 |
+
dfs={'edges': edges, 'nodes': nodes},
|
43 |
+
relations=[
|
44 |
+
RelationDefinition(
|
45 |
+
df='edges',
|
46 |
+
source_column='source',
|
47 |
+
target_column='target',
|
48 |
+
source_table='nodes',
|
49 |
+
target_table='nodes',
|
50 |
+
source_key='id',
|
51 |
+
target_key='id',
|
52 |
+
)
|
53 |
+
]
|
54 |
+
)
|
55 |
+
|
56 |
+
def to_nx(self):
|
57 |
+
graph = nx.from_pandas_edgelist(self.dfs['edges'])
|
58 |
+
nx.set_node_attributes(graph, self.dfs['nodes'].set_index('id').to_dict('index'))
|
59 |
+
return graph
|
60 |
+
|
61 |
+
|
62 |
+
def nx_node_attribute_func(name):
|
63 |
+
'''Decorator for wrapping a function that adds a NetworkX node attribute.'''
|
64 |
+
def decorator(func):
|
65 |
+
@functools.wraps(func)
|
66 |
+
def wrapper(graph: nx.Graph, **kwargs):
|
67 |
+
graph = graph.copy()
|
68 |
+
attr = func(graph, **kwargs)
|
69 |
+
nx.set_node_attributes(graph, attr, name)
|
70 |
+
return graph
|
71 |
+
return wrapper
|
72 |
+
return decorator
|
73 |
+
|
74 |
+
|
75 |
+
def disambiguate_edges(ws):
|
76 |
+
'''If an input plug is connected to multiple edges, keep only the last edge.'''
|
77 |
+
seen = set()
|
78 |
+
for edge in reversed(ws.edges):
|
79 |
+
if (edge.target, edge.targetHandle) in seen:
|
80 |
+
ws.edges.remove(edge)
|
81 |
+
seen.add((edge.target, edge.targetHandle))
|
82 |
+
|
83 |
+
|
84 |
+
@ops.register_executor('LynxKite')
|
85 |
+
def execute(ws):
|
86 |
+
catalog = ops.CATALOGS['LynxKite']
|
87 |
+
# Nodes are responsible for interpreting/executing their child nodes.
|
88 |
+
nodes = [n for n in ws.nodes if not n.parentId]
|
89 |
+
disambiguate_edges(ws)
|
90 |
+
children = {}
|
91 |
+
for n in ws.nodes:
|
92 |
+
if n.parentId:
|
93 |
+
children.setdefault(n.parentId, []).append(n)
|
94 |
+
outputs = {}
|
95 |
+
failed = 0
|
96 |
+
while len(outputs) + failed < len(nodes):
|
97 |
+
for node in nodes:
|
98 |
+
if node.id in outputs:
|
99 |
+
continue
|
100 |
+
# TODO: Take the input/output handles into account.
|
101 |
+
inputs = [edge.source for edge in ws.edges if edge.target == node.id]
|
102 |
+
if all(input in outputs for input in inputs):
|
103 |
+
inputs = [outputs[input] for input in inputs]
|
104 |
+
data = node.data
|
105 |
+
op = catalog[data.title]
|
106 |
+
params = {**data.params}
|
107 |
+
if op.sub_nodes:
|
108 |
+
sub_nodes = children.get(node.id, [])
|
109 |
+
sub_node_ids = [node.id for node in sub_nodes]
|
110 |
+
sub_edges = [edge for edge in ws.edges if edge.source in sub_node_ids]
|
111 |
+
params['sub_flow'] = {'nodes': sub_nodes, 'edges': sub_edges}
|
112 |
+
# Convert inputs.
|
113 |
+
for i, (x, p) in enumerate(zip(inputs, op.inputs.values())):
|
114 |
+
if p.type == nx.Graph and isinstance(x, Bundle):
|
115 |
+
inputs[i] = x.to_nx()
|
116 |
+
elif p.type == Bundle and isinstance(x, nx.Graph):
|
117 |
+
inputs[i] = Bundle.from_nx(x)
|
118 |
+
try:
|
119 |
+
output = op(*inputs, **params)
|
120 |
+
except Exception as e:
|
121 |
+
traceback.print_exc()
|
122 |
+
data.error = str(e)
|
123 |
+
failed += 1
|
124 |
+
continue
|
125 |
+
if len(op.inputs) == 1 and op.inputs.get('multi') == '*':
|
126 |
+
# It's a flexible input. Create n+1 handles.
|
127 |
+
data.inputs = {f'input{i}': None for i in range(len(inputs) + 1)}
|
128 |
+
data.error = None
|
129 |
+
outputs[node.id] = output
|
130 |
+
if op.type == 'visualization' or op.type == 'table_view':
|
131 |
+
data.display = output
|
132 |
+
|
133 |
@op("Import Parquet")
|
134 |
def import_parquet(*, filename: str):
|
135 |
'''Imports a parquet file.'''
|
|
|
141 |
return nx.scale_free_graph(nodes)
|
142 |
|
143 |
@op("Compute PageRank")
|
144 |
+
@nx_node_attribute_func('pagerank')
|
145 |
def compute_pagerank(graph: nx.Graph, *, damping=0.85, iterations=100):
|
146 |
return nx.pagerank(graph, alpha=damping, max_iter=iterations)
|
147 |
|
|
|
149 |
@op("Sample graph")
|
150 |
def sample_graph(graph: nx.Graph, *, nodes: int = 100):
|
151 |
'''Takes a subgraph.'''
|
152 |
+
return nx.scale_free_graph(nodes) # TODO: Implement this.
|
153 |
|
154 |
|
155 |
def _map_color(value):
|
|
|
159 |
return ['#{:02x}{:02x}{:02x}'.format(int(r*255), int(g*255), int(b*255)) for r, g, b in rgba[:, :3]]
|
160 |
|
161 |
@op("Visualize graph", view="visualization")
|
162 |
+
def visualize_graph(graph: Bundle, *, color_nodes_by: 'node_attribute' = None):
|
163 |
nodes = graph.dfs['nodes'].copy()
|
164 |
if color_nodes_by:
|
165 |
nodes['color'] = _map_color(nodes[color_nodes_by])
|
|
|
202 |
return v
|
203 |
|
204 |
@op("View tables", view="table_view")
|
205 |
+
def view_tables(bundle: Bundle):
|
206 |
v = {
|
207 |
'dataframes': { name: {
|
208 |
'columns': [str(c) for c in df.columns],
|
|
|
212 |
'other': bundle.other,
|
213 |
}
|
214 |
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/ops.py
CHANGED
@@ -95,80 +95,12 @@ class Op(BaseConfig):
|
|
95 |
params[p] = int(params[p])
|
96 |
elif self.params[p].type == float:
|
97 |
params[p] = float(params[p])
|
98 |
-
# Convert inputs.
|
99 |
-
inputs = list(inputs)
|
100 |
-
for i, (x, p) in enumerate(zip(inputs, self.inputs.values())):
|
101 |
-
if p.type == nx.Graph and isinstance(x, Bundle):
|
102 |
-
inputs[i] = x.to_nx()
|
103 |
-
elif p.type == Bundle and isinstance(x, nx.Graph):
|
104 |
-
inputs[i] = Bundle.from_nx(x)
|
105 |
res = self.func(*inputs, **params)
|
106 |
return res
|
107 |
|
108 |
|
109 |
-
@dataclasses.dataclass
|
110 |
-
class RelationDefinition:
|
111 |
-
'''Defines a set of edges.'''
|
112 |
-
df: str # The DataFrame that contains the edges.
|
113 |
-
source_column: str # The column in the edge DataFrame that contains the source node ID.
|
114 |
-
target_column: str # The column in the edge DataFrame that contains the target node ID.
|
115 |
-
source_table: str # The DataFrame that contains the source nodes.
|
116 |
-
target_table: str # The DataFrame that contains the target nodes.
|
117 |
-
source_key: str # The column in the source table that contains the node ID.
|
118 |
-
target_key: str # The column in the target table that contains the node ID.
|
119 |
-
|
120 |
-
@dataclasses.dataclass
|
121 |
-
class Bundle:
|
122 |
-
'''A collection of DataFrames and other data.
|
123 |
-
|
124 |
-
Can efficiently represent a knowledge graph (homogeneous or heterogeneous) or tabular data.
|
125 |
-
It can also carry other data, such as a trained model.
|
126 |
-
'''
|
127 |
-
dfs: dict[str, pd.DataFrame] = dataclasses.field(default_factory=dict)
|
128 |
-
relations: list[RelationDefinition] = dataclasses.field(default_factory=list)
|
129 |
-
other: dict[str, typing.Any] = None
|
130 |
-
|
131 |
-
@classmethod
|
132 |
-
def from_nx(cls, graph: nx.Graph):
|
133 |
-
edges = nx.to_pandas_edgelist(graph)
|
134 |
-
d = dict(graph.nodes(data=True))
|
135 |
-
nodes = pd.DataFrame(d.values(), index=d.keys())
|
136 |
-
nodes['id'] = nodes.index
|
137 |
-
return cls(
|
138 |
-
dfs={'edges': edges, 'nodes': nodes},
|
139 |
-
relations=[
|
140 |
-
RelationDefinition(
|
141 |
-
df='edges',
|
142 |
-
source_column='source',
|
143 |
-
target_column='target',
|
144 |
-
source_table='nodes',
|
145 |
-
target_table='nodes',
|
146 |
-
source_key='id',
|
147 |
-
target_key='id',
|
148 |
-
)
|
149 |
-
]
|
150 |
-
)
|
151 |
-
|
152 |
-
def to_nx(self):
|
153 |
-
graph = nx.from_pandas_edgelist(self.dfs['edges'])
|
154 |
-
nx.set_node_attributes(graph, self.dfs['nodes'].set_index('id').to_dict('index'))
|
155 |
-
return graph
|
156 |
-
|
157 |
-
|
158 |
-
def nx_node_attribute_func(name):
|
159 |
-
'''Decorator for wrapping a function that adds a NetworkX node attribute.'''
|
160 |
-
def decorator(func):
|
161 |
-
@functools.wraps(func)
|
162 |
-
def wrapper(graph: nx.Graph, **kwargs):
|
163 |
-
graph = graph.copy()
|
164 |
-
attr = func(graph, **kwargs)
|
165 |
-
nx.set_node_attributes(graph, attr, name)
|
166 |
-
return graph
|
167 |
-
return wrapper
|
168 |
-
return decorator
|
169 |
-
|
170 |
|
171 |
-
def op(env: str, name: str, *, view='basic', sub_nodes=None):
|
172 |
'''Decorator for defining an operation.'''
|
173 |
def decorator(func):
|
174 |
sig = inspect.signature(func)
|
@@ -179,10 +111,13 @@ def op(env: str, name: str, *, view='basic', sub_nodes=None):
|
|
179 |
if param.kind != param.KEYWORD_ONLY}
|
180 |
params = {}
|
181 |
for n, param in sig.parameters.items():
|
182 |
-
if param.kind == param.KEYWORD_ONLY:
|
183 |
params[n] = Parameter.basic(n, param.default, param.annotation)
|
184 |
-
outputs
|
185 |
-
|
|
|
|
|
|
|
186 |
if sub_nodes is not None:
|
187 |
op.sub_nodes = sub_nodes
|
188 |
op.type = 'sub_flow'
|
@@ -213,7 +148,7 @@ def output_position(**kwargs):
|
|
213 |
def no_op(*args, **kwargs):
|
214 |
if args:
|
215 |
return args[0]
|
216 |
-
return
|
217 |
|
218 |
def register_passive_op(env: str, name: str, inputs=[], outputs=['output'], params=[]):
|
219 |
'''A passive operation has no associated code.'''
|
|
|
95 |
params[p] = int(params[p])
|
96 |
elif self.params[p].type == float:
|
97 |
params[p] = float(params[p])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
98 |
res = self.func(*inputs, **params)
|
99 |
return res
|
100 |
|
101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
|
103 |
+
def op(env: str, name: str, *, view='basic', sub_nodes=None, outputs=None):
|
104 |
'''Decorator for defining an operation.'''
|
105 |
def decorator(func):
|
106 |
sig = inspect.signature(func)
|
|
|
111 |
if param.kind != param.KEYWORD_ONLY}
|
112 |
params = {}
|
113 |
for n, param in sig.parameters.items():
|
114 |
+
if param.kind == param.KEYWORD_ONLY and not n.startswith('_'):
|
115 |
params[n] = Parameter.basic(n, param.default, param.annotation)
|
116 |
+
if outputs:
|
117 |
+
_outputs = {name: Output(name=name, type=None) for name in outputs}
|
118 |
+
else:
|
119 |
+
_outputs = {'output': Output(name='output', type=None)} if view == 'basic' else {}
|
120 |
+
op = Op(func=func, name=name, params=params, inputs=inputs, outputs=_outputs, type=view)
|
121 |
if sub_nodes is not None:
|
122 |
op.sub_nodes = sub_nodes
|
123 |
op.type = 'sub_flow'
|
|
|
148 |
def no_op(*args, **kwargs):
|
149 |
if args:
|
150 |
return args[0]
|
151 |
+
return None
|
152 |
|
153 |
def register_passive_op(env: str, name: str, inputs=[], outputs=['output'], params=[]):
|
154 |
'''A passive operation has no associated code.'''
|
server/test_llm_ops.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import unittest
|
2 |
+
from . import llm_ops
|
3 |
+
from . import workspace
|
4 |
+
|
5 |
+
class LLMOpsTest(unittest.TestCase):
|
6 |
+
def testExecute(self):
|
7 |
+
ws = workspace.Workspace(env='LLM logic', nodes=[
|
8 |
+
workspace.WorkspaceNode(
|
9 |
+
id='0',
|
10 |
+
type='basic',
|
11 |
+
position=workspace.Position(x=0, y=0),
|
12 |
+
data=workspace.WorkspaceNodeData(title='Input', params={
|
13 |
+
'filename': '/Users/danieldarabos/Downloads/aimo-train.csv',
|
14 |
+
'key': 'problem',
|
15 |
+
})),
|
16 |
+
workspace.WorkspaceNode(
|
17 |
+
id='1',
|
18 |
+
type='table_view',
|
19 |
+
position=workspace.Position(x=0, y=0),
|
20 |
+
data=workspace.WorkspaceNodeData(title='View', params={})),
|
21 |
+
], edges=[
|
22 |
+
workspace.WorkspaceEdge(id='0-1', source='0', target='1', sourceHandle='', targetHandle=''),
|
23 |
+
])
|
24 |
+
llm_ops.execute(ws)
|
25 |
+
self.assertEqual('', ws.nodes[1].data.display)
|
26 |
+
|
27 |
+
if __name__ == '__main__':
|
28 |
+
unittest.main()
|
server/workspace.py
CHANGED
@@ -35,6 +35,8 @@ class WorkspaceEdge(BaseConfig):
|
|
35 |
id: str
|
36 |
source: str
|
37 |
target: str
|
|
|
|
|
38 |
|
39 |
class Workspace(BaseConfig):
|
40 |
env: str = ''
|
|
|
35 |
id: str
|
36 |
source: str
|
37 |
target: str
|
38 |
+
sourceHandle: str
|
39 |
+
targetHandle: str
|
40 |
|
41 |
class Workspace(BaseConfig):
|
42 |
env: str = ''
|
web/src/Directory.svelte
CHANGED
@@ -58,7 +58,9 @@
|
|
58 |
</div>
|
59 |
<div class="entry-list">
|
60 |
{#await list}
|
61 |
-
<div>
|
|
|
|
|
62 |
{:then list}
|
63 |
<div class="actions">
|
64 |
<a href="{newWorkspaceIn(path, list)}"><FilePlus /> New workspace</a>
|
@@ -168,4 +170,8 @@
|
|
168 |
color: black;
|
169 |
text-decoration: none;
|
170 |
}
|
|
|
|
|
|
|
|
|
171 |
</style>
|
|
|
58 |
</div>
|
59 |
<div class="entry-list">
|
60 |
{#await list}
|
61 |
+
<div class="loading spinner-border" role="status">
|
62 |
+
<span class="visually-hidden">Loading...</span>
|
63 |
+
</div>
|
64 |
{:then list}
|
65 |
<div class="actions">
|
66 |
<a href="{newWorkspaceIn(path, list)}"><FilePlus /> New workspace</a>
|
|
|
170 |
color: black;
|
171 |
text-decoration: none;
|
172 |
}
|
173 |
+
.loading {
|
174 |
+
color: #39bcf3;
|
175 |
+
margin: 10px;
|
176 |
+
}
|
177 |
</style>
|
web/src/EnvironmentSelector.svelte
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<script lang="ts">
|
2 |
+
export let options;
|
3 |
+
export let value;
|
4 |
+
export let onChange;
|
5 |
+
</script>
|
6 |
+
|
7 |
+
<select class="form-select form-select-sm"
|
8 |
+
value={value}
|
9 |
+
on:change={(evt) => onChange(evt.currentTarget.value)}
|
10 |
+
>
|
11 |
+
{#each options as option}
|
12 |
+
<option value={option}>{option}</option>
|
13 |
+
{/each}
|
14 |
+
</select>
|
web/src/LynxKiteFlow.svelte
CHANGED
@@ -160,15 +160,6 @@
|
|
160 |
console.log('changed', JSON.stringify(diff(g, $backendWorkspace.data), null, 2));
|
161 |
$mutation.mutate({ path, ws: g });
|
162 |
}
|
163 |
-
function onconnect(connection: Connection) {
|
164 |
-
edges.update((edges) => {
|
165 |
-
// Only one source can connect to a given target.
|
166 |
-
return edges.filter((e) =>
|
167 |
-
e.source === connection.source
|
168 |
-
|| e.target !== connection.target
|
169 |
-
|| e.targetHandle !== connection.targetHandle);
|
170 |
-
});
|
171 |
-
}
|
172 |
function nodeClick(e) {
|
173 |
const node = e.detail.node;
|
174 |
const meta = node.data.meta;
|
@@ -211,7 +202,6 @@
|
|
211 |
proOptions={{ hideAttribution: true }}
|
212 |
maxZoom={3}
|
213 |
minZoom={0.3}
|
214 |
-
onconnect={onconnect}
|
215 |
defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
|
216 |
>
|
217 |
<Controls />
|
|
|
160 |
console.log('changed', JSON.stringify(diff(g, $backendWorkspace.data), null, 2));
|
161 |
$mutation.mutate({ path, ws: g });
|
162 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
163 |
function nodeClick(e) {
|
164 |
const node = e.detail.node;
|
165 |
const meta = node.data.meta;
|
|
|
202 |
proOptions={{ hideAttribution: true }}
|
203 |
maxZoom={3}
|
204 |
minZoom={0.3}
|
|
|
205 |
defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
|
206 |
>
|
207 |
<Controls />
|
web/src/NodeWithTableView.svelte
CHANGED
@@ -6,18 +6,18 @@
|
|
6 |
type $$Props = NodeProps;
|
7 |
export let data: $$Props['data'];
|
8 |
const open = {};
|
9 |
-
$: single = data.
|
10 |
</script>
|
11 |
|
12 |
<LynxKiteNode {...$$props}>
|
13 |
-
{#if data.
|
14 |
-
{#each Object.entries(data.
|
15 |
{#if !single}<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>{/if}
|
16 |
{#if single || open[name]}
|
17 |
<Table columns={df.columns} data={df.data} />
|
18 |
{/if}
|
19 |
{/each}
|
20 |
-
{#each Object.entries(data.
|
21 |
<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>
|
22 |
{#if open[name]}
|
23 |
<pre>{o}</pre>
|
|
|
6 |
type $$Props = NodeProps;
|
7 |
export let data: $$Props['data'];
|
8 |
const open = {};
|
9 |
+
$: single = data.display?.dataframes && Object.keys(data.display.dataframes).length === 1;
|
10 |
</script>
|
11 |
|
12 |
<LynxKiteNode {...$$props}>
|
13 |
+
{#if data.display}
|
14 |
+
{#each Object.entries(data.display.dataframes) as [name, df]}
|
15 |
{#if !single}<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>{/if}
|
16 |
{#if single || open[name]}
|
17 |
<Table columns={df.columns} data={df.data} />
|
18 |
{/if}
|
19 |
{/each}
|
20 |
+
{#each Object.entries(data.display.others || {}) as [name, o]}
|
21 |
<div class="df-head" on:click={() => open[name] = !open[name]}>{name}</div>
|
22 |
{#if open[name]}
|
23 |
<pre>{o}</pre>
|
web/src/NodeWithVisualization.svelte
CHANGED
@@ -8,8 +8,8 @@
|
|
8 |
</script>
|
9 |
|
10 |
<NodeWithParams {...$$props}>
|
11 |
-
{#if data.
|
12 |
-
<Chart {init} options={data.
|
13 |
{/if}
|
14 |
</NodeWithParams>
|
15 |
<style>
|
|
|
8 |
</script>
|
9 |
|
10 |
<NodeWithParams {...$$props}>
|
11 |
+
{#if data.display}
|
12 |
+
<Chart {init} options={data.display} initOptions={{renderer: 'canvas', width: 250, height: 250}}/>
|
13 |
{/if}
|
14 |
</NodeWithParams>
|
15 |
<style>
|